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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user