Files
parking_solution/apps/server/src/booth-print.ts
T
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:37:26 +02:00

191 lines
7.4 KiB
TypeScript

import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
printWithFailover,
registry,
type PrinterDevice,
type PrinterInstance,
type ReceiptData,
type TicketHeader,
printerRoleOf,
} from "@parking/devices";
import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js";
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
// from the exit, the customer pays at the booth and walks a printed voucher to the
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
//
// This mirrors the entry flow's printer selection + header build, but prints on the
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
/** Park identity for the voucher header, from site_config (all fields optional). */
function ticketHeader(db: Db): TicketHeader | undefined {
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!row) return undefined;
return {
parkName: row.parkName,
operatorName: row.operatorName,
nius: row.nius,
address: row.address,
phone: row.phone,
};
}
/** Build live printer instances for failover selection (entry direction covers the
* booth-receipt role too — the booth printer is configured on the entry side). */
function loadPrinters(db: Db): PrinterInstance[] {
const rows = devicesByDirection(db, "printer", "entry");
const out: PrinterInstance[] = [];
for (const row of rows) {
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
const role = printerRoleOf(cfg);
try {
out.push({
id: row.id,
role,
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
}
/** The receipt figures for a paid session, folded from the SIGNED ledger
* (authoritative). Null if there's no entry or no payment for this id — the
* caller should have validated paid + open before printing. */
function receiptFigures(
db: Db,
ticketId: string,
): Omit<ReceiptData, "voucher" | "header"> | null {
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, ticketId))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
let payment: (typeof rows)[number] | undefined;
for (const r of rows) if (r.type === "payment") payment = r;
if (!payment) return null;
const p = (payment.payload ?? {}) as {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
graceExitMin?: number;
grossMinor?: number;
validationLines?: { label: string; discountMinor: number }[];
};
return {
ticketId,
enteredAt: entry.occurredAt,
paidAt: payment.occurredAt,
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
currency: p.currency ?? "ALL",
tender: p.tender === "card" ? "card" : "cash",
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
// Merchant validations, as settled on the signed payment (gross → lines → net).
grossMinor: typeof p.grossMinor === "number" ? p.grossMinor : null,
validationLines: Array.isArray(p.validationLines) ? p.validationLines : undefined,
};
}
/**
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
* to the entry dispenser). The receipt is the customer's transparency record:
* entry time, payment time, duration, amount + tender — folded from the signed
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
* walk-back grace, so the one slip both proves payment AND self-exits at a
* distant exit reader (this replaces the old barcode-only voucher). In standalone
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
* at the exit. Returns the id of the printer that printed it.
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
*/
export async function printPaymentReceipt(
db: Db,
ticketId: string,
opts: { voucher: boolean },
logger: FastifyBaseLogger,
): Promise<string> {
const figures = receiptFigures(db, ticketId);
if (!figures) {
throw new Error(`no paid session to receipt for ${ticketId}`);
}
const printers = loadPrinters(db);
const data: ReceiptData = {
...figures,
voucher: opts.voucher,
header: ticketHeader(db),
};
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReceipt(data),
);
logger.info(
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
);
return printedBy;
}
/**
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
* a scannable QR of the credential code + holder/validity, so the operator can hand
* it to the customer. Used on subscription creation and on a "reprint" action.
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
*/
export async function printSubscriptionCard(
db: Db,
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const data = {
code: card.code,
holderName: card.holderName ?? null,
validFrom: card.validFrom ?? null,
validTo: card.validTo ?? null,
header: ticketHeader(db),
};
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printSubscriptionCard(data),
);
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
/**
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement (early-entry AND any late-exit time
* combined). It just gives the subscriber paper proof that a fee is pending against this
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
* the caller swallows failures so a missing printer never blocks the barrier.
*/
export async function printWindowChargeNotice(
db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number | null; edge: "entry" | "exit" },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printWindowChargeNotice({
occurrenceId: notice.occurrenceId,
holderName: notice.holderName ?? null,
at: notice.at,
edge: notice.edge,
windowOpensMin: notice.windowOpensMin ?? null,
header: ticketHeader(db),
}),
);
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy;
}