feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s

Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 20:13:21 +02:00
parent 65328b8c11
commit 8a437d0c4b
19 changed files with 534 additions and 35 deletions
+33 -9
View File
@@ -26,6 +26,27 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
/**
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
*/
export function isRefusedWarning(e: LedgerEvent): boolean {
if (e.type !== "anomaly") return false;
const p = e.payload;
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
}
/** The label key + colour to render for an event, applying the refused-warning split. */
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
}
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
function hhmmss(iso: string): string {
const d = new Date(iso);
@@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string {
* its own row, indented under the identity column. */
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
const isAnomaly = e.type === "anomaly";
const style = eventStyleFor(e);
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
const refusedWarning = isRefusedWarning(e);
const isAnomaly = e.type === "anomaly" && !refusedWarning;
const p = e.payload;
const reason = renderReason(p, t);
const amount = paymentSummary(p);
@@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
type="button"
onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : ""
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`}
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
{e.plate && (
@@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
* this only DISPLAYS the signed record. */
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
const { t } = useTranslation();
const style = EVENT_STYLE[e.type];
const label = style ? t(style.labelKey) : e.type.toUpperCase();
const style = eventStyleFor(e);
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
const p = e.payload;
const reason = renderReason(p, t);
const badges = eventBadges(p);
const isAnomaly = e.type === "anomaly";
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
// Pretty money for any minor-unit amount in the payload.
const money =
@@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
<div className="flex flex-col gap-3">
{/* Headline: the type + localized reason, prominent for anomalies. */}
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money}