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
+5
View File
@@ -21,6 +21,7 @@ import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js";
import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js";
import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js";
@@ -129,6 +130,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db);
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded