644bfa1462
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.
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|