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 { 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): Promise { 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 { 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; } } }