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:
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) ----------------
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user