server+web: shifts — open/close + signed Z-report (manned mode)

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.
This commit is contained in:
2026-06-16 08:01:59 +02:00
parent 3429642edb
commit 644bfa1462
11 changed files with 406 additions and 0 deletions
+41
View File
@@ -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<void> {
// 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 });
}
});
}
+7
View File
@@ -10,6 +10,7 @@ import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js"; import { ExitFlow } from "./exit-flow.js";
import { PayStation } from "./pay-station.js"; import { PayStation } from "./pay-station.js";
import { PermitFlow } from "./permit-flow.js"; import { PermitFlow } from "./permit-flow.js";
import { ShiftService } from "./shift-service.js";
import { ReadDispatcher } from "./read-dispatch.js"; import { ReadDispatcher } from "./read-dispatch.js";
import { LaneMap } from "./lane-map.js"; import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js"; import { PrinterMonitor } from "./printer-monitor.js";
@@ -19,6 +20,7 @@ import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js"; import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js"; import { payRoutes } from "./routes/pay.js";
import { permitRoutes } from "./routes/permits.js"; import { permitRoutes } from "./routes/permits.js";
import { shiftRoutes } from "./routes/shift.js";
import { tariffRoutes } from "./routes/tariffs.js"; import { tariffRoutes } from "./routes/tariffs.js";
import { printerRoutes } from "./routes/printers.js"; import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js"; import { setupRoutes } from "./routes/setup.js";
@@ -122,6 +124,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Permit (subscription) admin CRUD. See wiki/entities/permit.md. // Permit (subscription) admin CRUD. See wiki/entities/permit.md.
await permitRoutes(app, db); await permitRoutes(app, db);
// Shifts (manned mode): explicit open/close → signed shift_open / shift_z_report
// (sum payments by tender, print the Z-report). See wiki/concepts/shift.md.
const shiftService = new ShiftService(db, eventLog, app.log);
await shiftRoutes(app, shiftService);
const unsubscribeInput = deviceEvents.onInput((e) => { const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't // 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 // mapped to a lane" (assigned without a lane, or a stale id) — still recorded
+180
View File
@@ -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<ShiftReport> {
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<ShiftReport, "printed">): Promise<boolean> {
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<PrinterDevice | null> {
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;
}
}
}
+2
View File
@@ -3,6 +3,7 @@ import { fetchMe, logout, type SessionUser } from "./api.js";
import { Login } from "./Login.js"; import { Login } from "./Login.js";
import { PermitManager } from "./PermitManager.js"; import { PermitManager } from "./PermitManager.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
import { ShiftControl } from "./ShiftControl.js";
import { TariffComposer } from "./TariffComposer.js"; import { TariffComposer } from "./TariffComposer.js";
// Operator UI shell. Plain React (no admin framework) — the operator UI is // Operator UI shell. Plain React (no admin framework) — the operator UI is
@@ -40,6 +41,7 @@ export function App() {
</button> </button>
</span> </span>
</header> </header>
{user.role !== "readonly" && <ShiftControl />}
{user.role === "admin" ? ( {user.role === "admin" ? (
<> <>
<SetupWizard /> <SetupWizard />
+83
View File
@@ -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<string | null>(null);
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<ShiftReport | null>(null);
const [err, setErr] = useState<string | null>(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 (
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
<strong>Shift:</strong>{" "}
{startedAt ? (
<>
<span style={{ color: "#16a34a" }}>open</span> since {new Date(startedAt).toLocaleString()}{" "}
<button type="button" onClick={end} disabled={busy}>
{busy ? "Ending…" : "End shift"}
</button>
</>
) : (
<>
<span style={{ color: "#777" }}>not started</span>{" "}
<button type="button" onClick={start} disabled={busy}>
{busy ? "Starting…" : "Start shift"}
</button>
</>
)}
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
{report && (
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
<div>Payments: {report.paymentCount}</div>
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
</div>
</div>
)}
</section>
);
}
+27
View File
@@ -277,3 +277,30 @@ export function revokePermit(id: string): Promise<Permit> {
export function deletePermit(id: string): Promise<void> { export function deletePermit(id: string): Promise<void> {
return apiFetch(`/api/permits/${id}`, { method: "DELETE" }); 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<ShiftStatus> {
return apiFetch("/api/shift/current");
}
export function openShift(): Promise<{ startedAt: string }> {
return apiFetch("/api/shift/open", { method: "POST" });
}
export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" });
}
@@ -6,6 +6,7 @@ import type {
MonitorableDevice, MonitorableDevice,
PrinterDevice, PrinterDevice,
PrinterStatus, PrinterStatus,
PrintReport,
TicketData, TicketData,
} from "../interfaces.js"; } from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.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])]); 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. */ /** Build the full ESC/POS byte stream for an entry ticket. */
function renderTicket(data: TicketData): Buffer { function renderTicket(data: TicketData): Buffer {
return Buffer.concat([ return Buffer.concat([
@@ -204,6 +220,11 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`); stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
} }
async printReport(report: PrintReport): Promise<void> {
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. * 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 * The board decodes the ESC/POS status bits itself, so we trust its Yes/No
+9
View File
@@ -199,6 +199,15 @@ export interface TicketData {
export interface PrinterDevice extends Device { export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>; printTicket(data: TicketData): Promise<void>;
/** 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<void>;
}
export interface PrintReport {
readonly title: string;
readonly lines: readonly string[];
} }
// --- Live printer status (consumable / mechanical faults) ---------------- // --- Live printer status (consumable / mechanical faults) ----------------
+3
View File
@@ -48,6 +48,9 @@ export type LedgerEventType =
// (loop/sensor) — reconciled against each other. // (loop/sensor) — reconciled against each other.
| "barrier_open_command" | "barrier_open_command"
| "barrier_open_observed" | "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" | "shift_z_report"
| "anomaly"; | "anomaly";
+18
View File
@@ -52,6 +52,24 @@ login ————————————————————————
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count, That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
no variance gate, no manager override. 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 ## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
+15
View File
@@ -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 create 403; update unbinds + REPLACES child rows (old cred gone); revoke→revoked; delete→204 then
404, children cleaned. Full build 5/5. 404, children cleaned. Full build 5/5.
- Updated [[permit]] (CRUD as-built). - 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).