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
@@ -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<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.
* 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 {
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) ----------------
+3
View File
@@ -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";