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:
@@ -11,6 +11,7 @@ import {
|
||||
} from "@parking/devices";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
import type { LaneMap } from "./lane-map.js";
|
||||
|
||||
@@ -77,6 +78,22 @@ export class EntryFlow {
|
||||
}
|
||||
|
||||
async #runEntry(lane: number, input: number, access: AccessControlDevice): Promise<void> {
|
||||
// CAPACITY GATE (transient only). When the lot is full, refuse transient entry:
|
||||
// no ticket, no vehicle_entry, no open — sign an anomaly. Permit holders are NOT
|
||||
// gated here (their flow ignores site-full; their own maxConcurrent applies), so
|
||||
// subscribers aren't locked out. "Full" is a soft policy seam for valet over-
|
||||
// capacity later. See wiki/concepts/capacity-occupancy.md.
|
||||
const occ = getOccupancy(this.#db);
|
||||
if (occ.full) {
|
||||
await this.#log.append({
|
||||
type: "anomaly",
|
||||
lane,
|
||||
payload: { reason: `transient entry refused — lot full (${occ.count}/${occ.capacity})`, entryRefused: true, full: true },
|
||||
});
|
||||
this.#logger.warn(`transient entry REFUSED on lane ${lane}: full (${occ.count}/${occ.capacity})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticketId = newTicketId();
|
||||
const issuedAt = new Date().toISOString();
|
||||
const printers = await this.#loadPrinters(lane);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { requireRole } from "../auth.js";
|
||||
import { getOccupancy } from "../occupancy.js";
|
||||
|
||||
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||
// ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at
|
||||
// capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||
|
||||
interface SiteConfigBody {
|
||||
/** Nominal capacity; null = no limit. */
|
||||
capacity: number | null;
|
||||
}
|
||||
|
||||
export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
|
||||
const writeGuard = requireRole("admin");
|
||||
|
||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||
|
||||
// Read site config (capacity).
|
||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
return { capacity: row?.capacity ?? null };
|
||||
});
|
||||
|
||||
// Set capacity (admin). null or 0+ integer.
|
||||
app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => {
|
||||
const { capacity } = req.body ?? ({} as SiteConfigBody);
|
||||
if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) {
|
||||
return reply.code(400).send({ error: "capacity must be a non-negative integer or null" });
|
||||
}
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const updatedAt = new Date().toISOString();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run();
|
||||
}
|
||||
return { capacity: capacity ?? null };
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user