Files
parking_solution/apps/server/src/occupancy.ts
T
julian 8a437d0c4b
CI / check (push) Failing after 56s
feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
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
2026-06-22 20:13:21 +02:00

113 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
// with no matching vehicle_exit. Never a hand-maintained counter (which is
// editable + drifts) — the chain is the truth. See wiki/concepts/capacity-occupancy.md.
export interface Occupancy {
/** Cars currently inside (open sessions). */
readonly count: number;
/** Spots HELD for active subscribers who are NOT currently parked (when the
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
* `quantity` spots minus however many of its cars are already inside. */
readonly reserved: number;
/** Admin-set nominal capacity, or null = no limit. */
readonly capacity: number | null;
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
readonly free: number | null;
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
readonly effectiveFree: number | null;
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
* (always false when uncapped). Subscribers are never gated by this. */
readonly full: boolean;
}
/** Count cars inside: entries minus exits, per identity, over the ledger. */
export function occupancyCount(db: Db): number {
const rows = db
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
.from(ledgerEvents)
.all();
const balance = new Map<string, number>();
for (const r of rows) {
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
// (misprint), so it must not count inside. See void-flow.ts.
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
else if (r.type === "vehicle_exit" || r.type === "void")
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
}
let open = 0;
for (const v of balance.values()) if (v > 0) open += 1;
return open;
}
/** Admin-set capacity (null = uncapped). */
export function siteCapacity(db: Db): number | null {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return row?.capacity ?? null;
}
/**
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
* subscription already inside (so we never double-count a parked subscriber). This is
* what makes a transient see "full" sooner while the subscriber's spot is held.
*/
export function reservedSubscriberSpots(db: Db): number {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!cfg?.reserveSubscriberSpots) return 0;
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const insidePerSub = new Map<string, number>();
const net = new Map<string, number>(); // occurrence identity → entries−exits
const subOf = new Map<string, string>(); // occurrence identity → subscription id
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const pl = (r.payload ?? {}) as { permitId?: string };
if (pl.permitId == null) continue; // transient
net.set(id, (net.get(id) ?? 0) + 1);
subOf.set(id, pl.permitId);
} else if (r.type === "vehicle_exit" || r.type === "void") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
}
}
for (const [id, n] of net) if (n > 0) {
const sub = subOf.get(id)!;
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
}
const now = new Date().toISOString();
const subs = db.select().from(subscriptions).all();
let reserved = 0;
for (const s of subs) {
const active =
s.status === "active" &&
(s.validFrom == null || now >= s.validFrom) &&
(s.validTo == null || now <= s.validTo);
if (!active) continue;
const qty = s.quantity ?? 1;
const inside = insidePerSub.get(s.id) ?? 0;
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
}
return reserved;
}
export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db);
const capacity = siteCapacity(db);
const reserved = reservedSubscriberSpots(db);
return {
count,
reserved,
capacity,
free: capacity == null ? null : capacity - count,
effectiveFree: capacity == null ? null : capacity - count - reserved,
// A transient is refused once physical cars + held subscriber spots reach capacity.
full: capacity != null && count + reserved >= capacity,
};
}