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:
2026-06-16 08:13:06 +02:00
parent 644bfa1462
commit e579fe5b6e
13 changed files with 983 additions and 1 deletions
+19
View File
@@ -304,3 +304,22 @@ export function openShift(): Promise<{ startedAt: string }> {
export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" });
}
// --- Site config / occupancy ----------------------------------------------
export interface Occupancy {
count: number;
capacity: number | null;
free: number | null;
full: boolean;
}
export function fetchOccupancy(): Promise<Occupancy> {
return apiFetch("/api/occupancy");
}
export function fetchSiteConfig(): Promise<{ capacity: number | null }> {
return apiFetch("/api/site-config");
}
export function setCapacity(capacity: number | null): Promise<{ capacity: number | null }> {
return apiFetch("/api/site-config", { method: "PUT", body: JSON.stringify({ capacity }) });
}