Files
parking_solution/packages/devices/src/printer-routing.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

103 lines
4.2 KiB
TypeScript

// Printer routing: pick which printer prints a given job across a lane's
// printers, with automatic failover. A lane has more than one printer — an
// entry dispenser outside (where the driver takes the ticket) and a booth
// printer inside (receipts, and a BACKUP for entry tickets if the dispenser is
// offline). See wiki/concepts/printer-roles-failover.md.
//
// This is pure selection logic over (config, health) — no device I/O — so the
// entry/exit flow can decide where to print without coupling to a transport.
import type { PrinterDevice } from "./interfaces.js";
import type { PrinterRole } from "./drivers/printer-rongta.js";
/** A configured printer instance + its live adapter, as the caller holds them. */
export interface PrinterInstance {
readonly id: string;
readonly role: PrinterRole;
/** Higher = preferred within a role. Ties broken by id for determinism. */
readonly failoverRank: number;
readonly device: PrinterDevice;
}
/**
* Order the candidate printers for a job targeting `wantRole`, best-first.
*
* Rule: printers of the wanted role come first (highest rank first); the booth
* printer is also a fallback for entry tickets, so when an entry ticket is
* routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser.
* The wash desk's slips (its till's Z-report and vouchers) fall back to the booth
* printer the same way — a site without a desk printer keeps printing them in the
* booth, as it did before the role existed. Nothing ever falls back TO the wash desk.
*/
export function orderForRole(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
): PrinterInstance[] {
const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2;
if (p.role === fallbackRole) return 1;
return 0;
};
return printers
.filter((p) => rank(p) > 0)
.sort((a, b) => {
if (rank(a) !== rank(b)) return rank(b) - rank(a); // wanted role first
if (a.failoverRank !== b.failoverRank) return b.failoverRank - a.failoverRank;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // stable tiebreak
});
}
/** The role a printer's saved config declares — ONE reading of the field, so a
* wash-desk printer is never mistaken for an entry dispenser by a loader that only
* knew two roles. Unknown/absent = entry-dispenser (the field's default). */
export function printerRoleOf(cfg: { role?: unknown } | null | undefined): PrinterRole {
const r = cfg?.role;
return r === "booth-receipt" || r === "wash-desk" ? r : "entry-dispenser";
}
export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) {
super(
attempts.length === 0
? "no printer configured for this job"
: `all ${attempts.length} candidate printer(s) failed: ${attempts
.map((a) => `${a.id} (${a.error})`)
.join(", ")}`,
);
this.name = "NoPrinterAvailableError";
}
}
/**
* Print `job` on the best healthy printer for `wantRole`, failing over down the
* ordered list. Tries each candidate's print directly: a healthCheck race is
* pointless when the print itself is the real reachability test, so we just
* attempt the print and move on if it throws. Returns the id that succeeded.
*
* Throws {@link NoPrinterAvailableError} if every candidate fails — the caller
* (entry flow) decides what that means (e.g. raise the barrier without a paper
* ticket vs. hold). That policy is the flow's, not the printer's.
*/
export async function printWithFailover(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
job: (device: PrinterDevice) => Promise<void>,
): Promise<string> {
const ordered = orderForRole(printers, wantRole);
const attempts: { id: string; error: string }[] = [];
for (const p of ordered) {
try {
await job(p.device);
return p.id;
} catch (err) {
attempts.push({ id: p.id, error: (err as Error).message });
}
}
throw new NoPrinterAvailableError(attempts);
}