From 644bfa1462463c20eb748f985ff86d0d8ee0195c Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Tue, 16 Jun 2026 08:01:59 +0200 Subject: [PATCH] =?UTF-8?q?server+web:=20shifts=20=E2=80=94=20open/close?= =?UTF-8?q?=20+=20signed=20Z-report=20(manned=20mode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shift is two signed ledger events, no mutable table: new shift_open event type + existing shift_z_report. The operator is the logged-in user (carried in event identity); a shift is open iff their latest shift event is a shift_open. ShiftService: close sums payment events in [start,end] by tender (cash/card, by payment time), appends the signed shift_z_report (totals/counts/window), and prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS text) to a booth-receipt printer. Print is best-effort — a failed print does not undo the signed close. Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open (409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the shell (non-readonly): Start/End + Z-report totals. Verified: open -> double-open 409 -> payments (cash+card; one outside the window excluded) -> close totals correct + signed + printed -> close-again 409 -> re-open ok; readonly 403; verifyChain ok. --- apps/server/src/routes/shift.ts | 41 ++++ apps/server/src/server.ts | 7 + apps/server/src/shift-service.ts | 180 ++++++++++++++++++ apps/web/src/App.tsx | 2 + apps/web/src/ShiftControl.tsx | 83 ++++++++ apps/web/src/api.ts | 27 +++ .../devices/src/drivers/printer-rongta.ts | 21 ++ packages/devices/src/interfaces.ts | 9 + packages/shared/src/index.ts | 3 + wiki/concepts/shift.md | 18 ++ wiki/log.md | 15 ++ 11 files changed, 406 insertions(+) create mode 100644 apps/server/src/routes/shift.ts create mode 100644 apps/server/src/shift-service.ts create mode 100644 apps/web/src/ShiftControl.tsx diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts new file mode 100644 index 0000000..d69893d --- /dev/null +++ b/apps/server/src/routes/shift.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from "fastify"; +import { requireRole } from "../auth.js"; +import { + NoOpenShiftError, + ShiftAlreadyOpenError, + type ShiftService, +} from "../shift-service.js"; + +// Shift endpoints (manned mode). The operator is the logged-in user; a shift is +// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and +// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. + +export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise { + // Cashier/operator/admin run shifts; readonly can't. + const guard = requireRole("admin", "operator", "cashier"); + + // Is the current operator's shift open? (For the UI to show Start vs. End.) + app.get("/api/shift/current", { preHandler: guard }, async (req) => { + const operator = req.user.username; + const open = shift.openShiftFor(operator); + return { operator, open: open ? { startedAt: open.occurredAt } : null }; + }); + + app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { + try { + return await shift.open(req.user.username); + } catch (err) { + if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message }); + return reply.code(500).send({ error: (err as Error).message }); + } + }); + + app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => { + try { + return await shift.close(req.user.username); + } catch (err) { + if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message }); + return reply.code(500).send({ error: (err as Error).message }); + } + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8f954fe..54f79cc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -10,6 +10,7 @@ import { EventLog } from "./event-log.js"; import { ExitFlow } from "./exit-flow.js"; import { PayStation } from "./pay-station.js"; import { PermitFlow } from "./permit-flow.js"; +import { ShiftService } from "./shift-service.js"; import { ReadDispatcher } from "./read-dispatch.js"; import { LaneMap } from "./lane-map.js"; import { PrinterMonitor } from "./printer-monitor.js"; @@ -19,6 +20,7 @@ import { deviceRoutes } from "./routes/devices.js"; 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 { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; @@ -122,6 +124,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise { // 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 diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts new file mode 100644 index 0000000..b5ab2a9 --- /dev/null +++ b/apps/server/src/shift-service.ts @@ -0,0 +1,180 @@ +import { eq, laneDevices, ledgerEvents, type Db } from "@parking/db"; +import { registry, type PrinterDevice } from "@parking/devices"; +import type { LedgerPayload } from "@parking/shared"; +import type { FastifyBaseLogger } from "fastify"; +import type { EventLog } from "./event-log.js"; + +// Shift service (manned mode only). A shift is an operator's accountability period, +// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger +// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the +// `payment` events taken during the shift by tender and print a Z-report. +// See wiki/concepts/shift.md. + +export class ShiftAlreadyOpenError extends Error { + constructor(operator: string) { + super(`operator ${operator} already has an open shift`); + this.name = "ShiftAlreadyOpenError"; + } +} +export class NoOpenShiftError extends Error { + constructor(operator: string) { + super(`operator ${operator} has no open shift`); + this.name = "NoOpenShiftError"; + } +} + +export interface ShiftReport { + readonly operator: string; + readonly startedAt: string; + readonly endedAt: string; + readonly cashTotalMinor: number; + readonly cardTotalMinor: number; + readonly currency: string | null; + readonly paymentCount: number; + readonly printed: boolean; +} + +export class ShiftService { + readonly #db: Db; + readonly #log: EventLog; + readonly #logger: FastifyBaseLogger; + + constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { + this.#db = db; + this.#log = log; + this.#logger = logger; + } + + /** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ + openShiftFor(operator: string) { + // Scan shift events for this operator; the shift is open if the most recent + // shift event for them is a `shift_open` (not yet closed by a z_report). + const rows = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.identity, operator)) + .orderBy(ledgerEvents.index) + .all() + .filter((r) => r.type === "shift_open" || r.type === "shift_z_report"); + const last = rows[rows.length - 1]; + return last && last.type === "shift_open" ? last : null; + } + + /** Open a shift for the operator (explicit start). */ + async open(operator: string): Promise<{ startedAt: string }> { + if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator); + const startedAt = new Date().toISOString(); + await this.#log.append({ + type: "shift_open", + lane: -1, + source: "manual", + identity: operator, // the shift's operator; `identity` keys the shift to them + payload: { operator }, + occurredAt: startedAt, + }); + this.#logger.info(`shift opened for ${operator}`); + return { startedAt }; + } + + /** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ + async close(operator: string): Promise { + const open = this.openShiftFor(operator); + if (!open) throw new NoOpenShiftError(operator); + const startedAt = open.occurredAt; + const endedAt = new Date().toISOString(); + + // All payments taken in [startedAt, endedAt], summed by tender. Payment time = + // the operator who handled the money (decision: sum by payment time). + const payments = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.type, "payment")) + .all() + .filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt); + + let cashTotalMinor = 0; + let cardTotalMinor = 0; + let currency: string | null = null; + for (const p of payments) { + const pl = (p.payload ?? {}) as LedgerPayload; + const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; + if (pl.tender === "card") cardTotalMinor += amt; + else cashTotalMinor += amt; + if (pl.currency) currency = pl.currency; + } + + await this.#log.append({ + type: "shift_z_report", + lane: -1, + source: "manual", + identity: operator, + payload: { + operator, + startedAt, + endedAt, + cashTotalMinor, + cardTotalMinor, + currency: currency ?? undefined, + paymentCount: payments.length, + }, + }); + + const printed = await this.#printZReport({ + operator, + startedAt, + endedAt, + cashTotalMinor, + cardTotalMinor, + currency, + paymentCount: payments.length, + }); + + this.#logger.info( + `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`, + ); + return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed }; + } + + /** Print the Z-report on a booth-receipt printer (best-effort; the signed event + * is the record — a failed print doesn't undo the close). */ + async #printZReport(r: Omit): Promise { + const printer = await this.#boothPrinter(); + if (!printer) { + this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`); + return false; + } + const cur = r.currency ?? ""; + const money = (m: number) => (m / 100).toFixed(2); + const lines = [ + `Operator: ${r.operator}`, + `From: ${r.startedAt}`, + `To: ${r.endedAt}`, + "", + `Payments: ${r.paymentCount}`, + `Cash: ${money(r.cashTotalMinor)} ${cur}`, + `Card: ${money(r.cardTotalMinor)} ${cur}`, + ]; + try { + await printer.printReport({ title: "SHIFT Z-REPORT", lines }); + return true; + } catch (err) { + this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`); + return false; + } + } + + /** First enabled booth-receipt printer (any lane), or any enabled printer. */ + async #boothPrinter(): Promise { + const rows = await this.#db.select().from(laneDevices).where(eq(laneDevices.category, "printer")).all(); + const enabled = rows.filter((r) => r.enabled); + const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; + if (!booth) return null; + const driver = registry.get(booth.driverId); + if (!driver) return null; + try { + return driver.create(booth.config as never) as PrinterDevice; + } catch { + return null; + } + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index afc731e..e942b11 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { fetchMe, logout, type SessionUser } from "./api.js"; import { Login } from "./Login.js"; import { PermitManager } from "./PermitManager.js"; import { SetupWizard } from "./SetupWizard.js"; +import { ShiftControl } from "./ShiftControl.js"; import { TariffComposer } from "./TariffComposer.js"; // Operator UI shell. Plain React (no admin framework) — the operator UI is @@ -40,6 +41,7 @@ export function App() { + {user.role !== "readonly" && } {user.role === "admin" ? ( <> diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx new file mode 100644 index 0000000..c158526 --- /dev/null +++ b/apps/web/src/ShiftControl.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from "react"; +import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js"; + +// Manned-mode shift control. Start/End are explicit (not time-based — see +// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the +// totals. Available to cashier/operator/admin (readonly has no shift). + +const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim(); + +export function ShiftControl() { + const [startedAt, setStartedAt] = useState(null); + const [busy, setBusy] = useState(false); + const [report, setReport] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + fetchShift() + .then((s) => setStartedAt(s.open?.startedAt ?? null)) + .catch(() => { + /* readonly / not permitted — hide control */ + }); + }, []); + + async function start() { + setBusy(true); + setErr(null); + setReport(null); + try { + const { startedAt } = await openShift(); + setStartedAt(startedAt); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + } + async function end() { + setBusy(true); + setErr(null); + try { + const z = await closeShift(); + setReport(z); + setStartedAt(null); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + } + + return ( +
+ Shift:{" "} + {startedAt ? ( + <> + open since {new Date(startedAt).toLocaleString()}{" "} + + + ) : ( + <> + not started{" "} + + + )} + {err &&

{err}

} + {report && ( +
+
Z-REPORT — {report.operator}
+
Payments: {report.paymentCount}
+
Cash: {money(report.cashTotalMinor, report.currency)}
+
Card: {money(report.cardTotalMinor, report.currency)}
+
+ {report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 15ca433..97dbfe8 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -277,3 +277,30 @@ export function revokePermit(id: string): Promise { export function deletePermit(id: string): Promise { return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); } + +// --- Shifts --------------------------------------------------------------- + +export interface ShiftStatus { + operator: string; + open: { startedAt: string } | null; +} +export interface ShiftReport { + operator: string; + startedAt: string; + endedAt: string; + cashTotalMinor: number; + cardTotalMinor: number; + currency: string | null; + paymentCount: number; + printed: boolean; +} + +export function fetchShift(): Promise { + return apiFetch("/api/shift/current"); +} +export function openShift(): Promise<{ startedAt: string }> { + return apiFetch("/api/shift/open", { method: "POST" }); +} +export function closeShift(): Promise { + return apiFetch("/api/shift/close", { method: "POST" }); +} diff --git a/packages/devices/src/drivers/printer-rongta.ts b/packages/devices/src/drivers/printer-rongta.ts index 48bcecd..bbc5d10 100644 --- a/packages/devices/src/drivers/printer-rongta.ts +++ b/packages/devices/src/drivers/printer-rongta.ts @@ -6,6 +6,7 @@ import type { MonitorableDevice, PrinterDevice, PrinterStatus, + PrintReport, TicketData, } from "../interfaces.js"; import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js"; @@ -44,6 +45,21 @@ function line(text = ""): Buffer { return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]); } +/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */ +function renderReport(report: PrintReport): Buffer { + return Buffer.concat([ + INIT, + ALIGN_CENTER, + BOLD_ON, + line(report.title), + BOLD_OFF, + ALIGN_LEFT, + line(), + ...report.lines.map((l) => line(l)), + FEED_AND_CUT, + ]); +} + /** Build the full ESC/POS byte stream for an entry ticket. */ function renderTicket(data: TicketData): Buffer { return Buffer.concat([ @@ -204,6 +220,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice { stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`); } + async printReport(report: PrintReport): Promise { + await sendRaw(this.#host, this.#port, renderReport(report), this.#timeout); + stubLog(this.driverId, `printed report "${report.title}" (${report.lines.length} lines)`); + } + /** * Live operator-actionable status, scraped from the device's own status page. * The board decodes the ESC/POS status bits itself, so we trust its Yes/No diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index a552e51..2f4dfe6 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -199,6 +199,15 @@ export interface TicketData { export interface PrinterDevice extends Device { printTicket(data: TicketData): Promise; + /** Print a free-form text report (a shift Z-report, a receipt). `lines` are + * printed as-is; the driver adds a header/cut. Kept generic so the business + * layer composes the content. See wiki/concepts/shift.md. */ + printReport(report: PrintReport): Promise; +} + +export interface PrintReport { + readonly title: string; + readonly lines: readonly string[]; } // --- Live printer status (consumable / mechanical faults) ---------------- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6af5268..2711684 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -48,6 +48,9 @@ export type LedgerEventType = // (loop/sensor) — reconciled against each other. | "barrier_open_command" | "barrier_open_observed" + // Manned-mode shift boundary: an operator takes over (shift_open) / hands over + // with a takings summary (shift_z_report). See wiki/concepts/shift.md. + | "shift_open" | "shift_z_report" | "anomaly"; diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index 57b7bd4..ee30b0b 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -52,6 +52,24 @@ login ———————————————————————— That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count, no variance gate, no manager override. +### As-built (2026-06-16) + +- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event + type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the + event `identity`; a shift is **open** iff that operator's most recent shift event is a + `shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`). +- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment + time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the + new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt + printer. Printing is best-effort — a failed print does **not** undo the signed close (the event is + the record; `printed:false` is returned). +- **Routes** (`routes/shift.ts`, cashier/operator/admin): `GET /api/shift/current`, + `POST /api/shift/open` (409 if already open), `POST /api/shift/close` (409 if none open). + **UI** `ShiftControl` in the app shell (non-readonly): Start/End + the Z-report totals. +- Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) → + close totals correct + signed + printed → close-again 409 → re-open works; readonly 403; + verifyChain ok. + ## Where the fraud control actually lives Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the diff --git a/wiki/log.md b/wiki/log.md index 56cfadf..3bd051a 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -577,3 +577,18 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then 404, children cleaned. Full build 5/5. - Updated [[permit]] (CRUD as-built). + +## [2026-06-16] build | Shifts: open/close + signed Z-report (manned mode) +- Shift = two signed ledger events, NO mutable table: new `shift_open` event type + existing + `shift_z_report`. Operator = logged-in user (in event `identity`); open iff their latest shift + event is a `shift_open`. `apps/server/src/shift-service.ts`. +- Close sums `payment` events in the window by tender (cash/card, by payment time) → signed + `shift_z_report` (totals/counts/window) → prints via the NEW generic + `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt printer. + Print is best-effort — failure doesn't undo the signed close (`printed:false` returned). +- Routes (`routes/shift.ts`, cashier/operator/admin): GET /api/shift/current, POST open (409 if + open), POST close (409 if none). UI `ShiftControl` in the shell (non-readonly): Start/End + Z totals. +- Added `printReport` to the PrinterDevice interface + Rongta driver (reusable for receipts later). +- VERIFIED: open→double-open 409→payments (cash+card; one dated outside the window excluded)→close + totals (cash 500/card 250/3)→close-again 409→re-open ok; readonly 403; verifyChain ok. Full build 5/5. +- Updated [[shift]] (as-built).