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 { 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<FastifyInsta
|
||||
// Permit (subscription) admin CRUD. See wiki/entities/permit.md.
|
||||
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) => {
|
||||
// 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
|
||||
|
||||
@@ -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 { 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() {
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
{user.role !== "readonly" && <ShiftControl />}
|
||||
{user.role === "admin" ? (
|
||||
<>
|
||||
<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> {
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -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) ----------------
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user