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
This commit is contained in:
2026-09-06 12:37:26 +02:00
parent ea304bbfd1
commit e14e31a840
27 changed files with 414 additions and 91 deletions
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
// --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md.
export type PrinterRole = "entry-dispenser" | "booth-receipt";
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
// --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
@@ -128,8 +128,9 @@ const roleField: ConfigField = {
label: "Entry dispenser (outside / at the lane)",
},
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
};
const rankField: ConfigField = {
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
}
}
/** Type guard: does this device carry a printer role (entry vs. booth)? */
export type PrinterRole = "entry-dispenser" | "booth-receipt";
/** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
* the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
const roleField: ConfigField = {
key: "role",
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
label: "Entry dispenser (outside / at the lane)",
},
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
};
const rankField: ConfigField = {
+1
View File
@@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
export {
orderForRole,
printWithFailover,
printerRoleOf,
NoPrinterAvailableError,
type PrinterInstance,
} from "./printer-routing.js";
@@ -27,6 +27,19 @@ describe("orderForRole", () => {
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
});
it("wash-desk job: the desk printer first, the booth printer as fallback, never the dispenser", () => {
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt"), inst("desk", "wash-desk")];
expect(orderForRole(printers, "wash-desk").map((p) => p.id)).toEqual(["desk", "booth"]);
// A site without a desk printer keeps printing wash slips in the booth.
expect(orderForRole(printers.slice(0, 2), "wash-desk").map((p) => p.id)).toEqual(["booth"]);
});
it("nothing ever falls back TO the wash desk (booth receipts and entry tickets stay off it)", () => {
const printers = [inst("desk", "wash-desk")];
expect(orderForRole(printers, "booth-receipt")).toEqual([]);
expect(orderForRole(printers, "entry-dispenser")).toEqual([]);
});
it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [
inst("b", "entry-dispenser", 1),
+12 -1
View File
@@ -26,13 +26,16 @@ export interface PrinterInstance {
* 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" ? "booth-receipt" : null;
wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2;
@@ -49,6 +52,14 @@ export function orderForRole(
});
}
/** 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(