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
+111 -48
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared";
import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
@@ -71,12 +71,19 @@ export interface ShiftSummary {
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number;
readonly chargesByModuleMinor: ChargesByModule;
readonly openingFloatMinor: number;
readonly cashAddedMinor: number;
readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number;
}
/** Module money folded into this till's payments as `chargeLines`, by owning module —
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
* that actually charged in the window appear. Cash+card already contain it; it is
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
export type ChargesByModule = Partial<Record<ModuleId, number>>;
export interface ShiftReport {
readonly till: TillId;
readonly operator: string;
@@ -98,6 +105,8 @@ export interface ShiftReport {
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number;
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
readonly chargesByModuleMinor: ChargesByModule;
// --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number;
@@ -142,6 +151,15 @@ export class InvalidCashMovementError extends Error {
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
* desk sells washes) and the label a module's charge gets when it rides another
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
* the booth printer when it has none of its own (orderForRole); the booth never falls
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
export class ShiftService {
readonly #db: Db;
@@ -248,6 +266,7 @@ export class ShiftService {
subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number;
discountTotalMinor?: number;
chargesByModuleMinor?: ChargesByModule;
openingFloatMinor?: number;
cashAddedMinor?: number;
cashRemovedMinor?: number;
@@ -283,6 +302,8 @@ export class ShiftService {
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0,
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -558,8 +579,8 @@ export class ShiftService {
.from(ledgerEvents)
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
// parking payment's amount already, as chargeLines). Both fold into the cash/card
// tender totals so the expected drawer is right; a separate wash bucket on the
// Z-report is a follow-up (venue-modules.md).
// tender totals so the expected drawer is right; the booth-paid wash is then
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all()
.filter(
@@ -578,11 +599,16 @@ export class ShiftService {
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0;
// Module charges folded into this till's payments (chargeLines on a booth payment),
// summed by owning module. Part of cash/card; NOT ticket money.
const chargesByModuleMinor: ChargesByModule = {};
let chargesTotalMinor = 0;
let currency: string | null = null;
for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
chargeLines?: ChargeLine[];
};
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt;
@@ -591,10 +617,17 @@ export class ShiftService {
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
for (const l of pl.chargeLines ?? []) {
if (typeof l.amountMinor !== "number" || !l.module) continue;
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
chargesTotalMinor += l.amountMinor;
}
if (pl.currency) currency = pl.currency;
}
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
// Ticket = what is left once subscriber money and module charges are taken out:
// ticket + subscriptions + Σcharges = cash + card, always.
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
// --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start);
@@ -649,6 +682,7 @@ export class ShiftService {
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -689,6 +723,7 @@ export class ShiftService {
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -713,6 +748,8 @@ export class ShiftService {
subscriptionSalesMinor,
subscriptionWindowMinor,
discountTotalMinor,
// Only when a module charged in the window (older slips/payloads stay identical).
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -729,14 +766,9 @@ export class ShiftService {
return { ...report, printed };
}
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
* is the record — a failed print doesn't undo the close). */
/** Print the Z-report on the till's printer (best-effort; the signed event is the
* record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
return false;
}
const cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
@@ -754,11 +786,23 @@ export class ShiftService {
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"",
"-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
// The booth prints its three classic lines (byte-identical to before tills); a
// module's till prints its own takings under its own name — it sells no tickets
// and no subscriptions.
...(r.till === BOOTH_TILL
? [
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
]
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
// only when any was taken, so the operator sees parking and wash money apart.
...Object.entries(r.chargesByModuleMinor)
.filter(([, v]) => (v ?? 0) > 0)
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
// Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
@@ -770,13 +814,7 @@ export class ShiftService {
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
];
try {
await printer.printReport({ title: "RAPORT TURNI", lines });
return true;
} catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
return false;
}
return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
}
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
@@ -792,11 +830,6 @@ export class ShiftService {
at: string;
till: TillId;
}): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
@@ -810,27 +843,57 @@ export class ShiftService {
"",
`Regjistroi: ${v.operator}`,
];
try {
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
}
/** First enabled booth-receipt printer, or any enabled printer. */
async #boothPrinter(): Promise<PrinterDevice | null> {
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const enabled = rows.filter((r) => r.enabled);
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
if (!booth) return null;
const driver = registry.get(booth.driverId);
if (!driver) return null;
try {
return driver.create(booth.config as never) as PrinterDevice;
} catch {
return null;
/** Print a till's slip on its printer with failover (wash desk → booth printer;
* see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
* record — every failure is logged and reported as "not printed", never thrown.
* Legacy fallback: a site whose only printer carries no booth role (one unit,
* configured as the entry dispenser) still prints its slips on it, as before. */
async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
const printers = this.#loadPrinters();
const want = TILL_PRINTER_ROLE[till];
let ordered = orderForRole(printers, want);
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
if (ordered.length === 0) {
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
return false;
}
const attempts: string[] = [];
for (const p of ordered) {
try {
await job(p.device);
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
return true;
} catch (err) {
attempts.push(`${p.id} (${(err as Error).message})`);
}
}
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
return false;
}
/** Every enabled printer as a live instance (role + rank from its saved config). */
#loadPrinters(): PrinterInstance[] {
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const out: PrinterInstance[] = [];
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
try {
out.push({
id: row.id,
role: printerRoleOf(cfg),
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;
}
}