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:
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user