server+web: capacity / FULL gate (occupancy fold + transient refuse)
Occupancy is a fold over the signed ledger (entries minus exits per identity);
getOccupancy returns {count, capacity, free, full}. Capacity is a single-row
site_config table (admin-set; null = uncapped; migration 0001, additive).
FULL gate lives in the transient entry flow: when full, refuse (no ticket, no
vehicle_entry, no open) and sign an anomaly. Permit entry is NOT gated --
subscribers are admitted past transient-full (their own maxConcurrent still
applies), so occupancy can read over capacity by design (reserve-for-permits).
Routes: GET /api/occupancy + GET /api/site-config (any role), PUT
/api/site-config (admin; non-negative int or null). Web SiteSettings: live
occupancy + FULL badge (everyone), capacity editor (admin).
Verified: fill to cap -> 3rd transient refused; permit admitted past full; exit
frees a slot; RBAC (operator can't set, -5 -> 400); verifyChain ok. Physical
FULL-sign relay output deferred.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { eq, ledgerEvents, siteConfig, 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;
|
||||
/** 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;
|
||||
/** True when count ≥ capacity (always false when uncapped). */
|
||||
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) {
|
||||
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||
else if (r.type === "vehicle_exit") 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;
|
||||
}
|
||||
|
||||
export function getOccupancy(db: Db): Occupancy {
|
||||
const count = occupancyCount(db);
|
||||
const capacity = siteCapacity(db);
|
||||
return {
|
||||
count,
|
||||
capacity,
|
||||
free: capacity == null ? null : capacity - count,
|
||||
full: capacity != null && count >= capacity,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user