e14e31a840
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
103 lines
4.2 KiB
TypeScript
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);
|
|
}
|