diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 47109a0..9cda95e 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -222,6 +222,22 @@ export function requirePermission(...required: Permission[]) { }; } +/** + * preHandler guard satisfied by ANY ONE of the listed permissions — for a read that + * two jobs legitimately share (a module's master data: the desk that works with it + * reads it under the module's own permission, Setup reads it under site:read). + */ +export function requireAnyPermission(...anyOf: Permission[]) { + return async (req: FastifyRequest, _reply: FastifyReply) => { + await req.jwtVerify(); + assertCsrf(req); + refreshRole(req); + if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) { + throw Object.assign(new Error("forbidden"), { statusCode: 403 }); + } + }; +} + /** * preHandler that requires a valid signed-in session but NO specific permission — * for "about me" routes (/me, change own language) every authenticated user may diff --git a/apps/server/src/booth-print.ts b/apps/server/src/booth-print.ts index ecb0532..f6586df 100644 --- a/apps/server/src/booth-print.ts +++ b/apps/server/src/booth-print.ts @@ -6,6 +6,7 @@ import { type PrinterInstance, type ReceiptData, type TicketHeader, + printerRoleOf, } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import { devicesByDirection } from "./device-resolve.js"; @@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] { const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; - const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; + const role = printerRoleOf(cfg); try { out.push({ id: row.id, diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index 51255cc..b7821fc 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -52,7 +52,7 @@ export interface ReadOutcome { export interface PrinterStatusEvent { readonly deviceId: string; // devices id readonly driverId: string; - readonly role?: string; // entry-dispenser | booth-receipt + readonly role?: string; // entry-dispenser | booth-receipt | wash-desk readonly status: PrinterStatus; } @@ -74,10 +74,10 @@ export interface DeviceStatusEvent { * chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina": * - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay) * - access: "entry" | "exit" | "both" | "mixed" (from its relays[]) - * - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) + * - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk) * - undetermined: null (chip shows the category alone) */ - readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; + readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null; readonly state: "ready" | "degraded" | "offline"; readonly detail?: string; readonly checkedAt: string; // ISO-8601 diff --git a/apps/server/src/device-monitor.ts b/apps/server/src/device-monitor.ts index b14af43..69b4e06 100644 --- a/apps/server/src/device-monitor.ts +++ b/apps/server/src/device-monitor.ts @@ -64,7 +64,7 @@ export function localIsoWithOffset(tz: string, at = new Date()): string { * - reader/camera → the direction inherited from its bound relay (entry/exit/both) * - access → entry/exit/both from its relays[]; "mixed" if it spans more * than one direction; null if it declares none yet - * - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) + * - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk) */ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { switch (row.category) { @@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { const role = (row.config as { role?: string }).role; if (role === "booth-receipt") return "booth"; if (role === "entry-dispenser") return "lane"; + if (role === "wash-desk") return "wash"; return null; } default: diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 93fa2bc..3b96e14 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -9,6 +9,7 @@ import { type PrinterInstance, type TicketData, type TicketHeader, + printerRoleOf, } from "@parking/devices"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; @@ -523,7 +524,7 @@ export class EntryFlow { const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; - const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; + const role = printerRoleOf(cfg); try { out.push({ id: row.id, diff --git a/apps/server/src/modules/carwash/carwash.test.ts b/apps/server/src/modules/carwash/carwash.test.ts index 902b7db..1bf4542 100644 --- a/apps/server/src/modules/carwash/carwash.test.ts +++ b/apps/server/src/modules/carwash/carwash.test.ts @@ -188,6 +188,17 @@ describe("orders", () => { // A second lookup no longer carries the line (it's settled). const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } }); expect(again.json().chargeLines).toEqual([]); + + // The booth's Z-report: the wash money is inside cash (it is in the drawer) but + // OUT of the ticket bucket, under its own module — Bileta is parking money only. + const parking = payment.payload.parkingMinor as number; + const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json(); + expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } }); + expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor); + const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0]; + expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } }); + const signed = (await events(a)).find((e) => e.type === "shift_z_report")!; + expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 }); }); it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => { @@ -429,6 +440,12 @@ describe("tills are gated by the module permission", () => { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], }); const w = await login(app, washer.username, washer.password); + // The desk's category/service pickers come from the settings read — the job has no + // site:read, so the module permission must open it (found on park dev, 2026-09-06). + const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } }); + expect(list.statusCode).toBe(200); + expect(list.json().categories.length).toBeGreaterThan(0); + expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403); // What the UI offers: only the wash till. const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } }); expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]); @@ -492,3 +509,54 @@ describe("a role reassignment takes effect without re-login", () => { expect(me.roleId).toBe("wash-op"); }); }); + +describe("a shift's activity log is per till", () => { + it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => { + const a = await admin(); + seedTariff(db, { pricePerIncrementMinor: 10000 }); + const ids = await seedSettings(a); + await openSession("T-L"); + await setPayAt(a, "bay"); + await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) }); + await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } }); + const order = (await app.inject({ + method: "POST", url: "/api/carwash/orders", headers: hdrs(a), + payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std }, + })).json(); + await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) }); + await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } }); + await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } }); + await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } }); + + const types = async (qs: string, auth: Auth = a) => { + const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } }); + expect(r.statusCode).toBe(200); + return (r.json().events as { type: string; payload: Record }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`); + }; + // The wash till's log: its shift, its order (no money moved, but wash-desk activity), + // its bay payment and its voucher — none of the booth's. + const wash = await types("&till=carwash"); + expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"])); + expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false); + // The booth's log: entry, its shift, its voucher — and no wash-desk activity. + const booth = await types("&till=booth"); + expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"])); + expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false); + // No till → everything (unchanged). + const all = await types(""); + expect(all.length).toBe(wash.length + booth.length); + expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400); + + // A wash operator holds carwash:read but not event:read: the log opens for them + // with ONLY the module's own event types (the live-socket rule, feedPermissionFor). + const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] }); + const w = await login(app, washer.username, washer.password); + const mine = await types("&till=carwash", w); + expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"])); + expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true); + // A role with neither event:read nor any module feed permission reads nothing. + const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] }); + const c = await login(app, clerk.username, clerk.password); + expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403); + }); +}); diff --git a/apps/server/src/modules/carwash/routes.ts b/apps/server/src/modules/carwash/routes.ts index e4b6330..87bf7f7 100644 --- a/apps/server/src/modules/carwash/routes.ts +++ b/apps/server/src/modules/carwash/routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { Tender } from "@parking/shared"; -import { requirePermission } from "../../auth.js"; +import { requireAnyPermission, requirePermission } from "../../auth.js"; import { requireModule } from "../../modules.js"; import { NoShiftOpenError } from "../../shift-service.js"; import type { ServerModuleDeps } from "../index.js"; @@ -29,7 +29,9 @@ function sendError(reply: FastifyReply, err: unknown): FastifyReply { export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise { const moduleOn = requireModule(deps.db, "carwash"); - const settingsRead = [moduleOn, requirePermission("site:read")]; + // The price list is the desk's working data as much as Setup's: the wash operator + // reads it under the module's own permission (the Wash operator job holds no site:*). + const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")]; const settingsWrite = [moduleOn, requirePermission("site:update")]; const read = [moduleOn, requirePermission("carwash:read")]; const create = [moduleOn, requirePermission("carwash:create")]; diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index 572475c..04a8b6e 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -1,7 +1,8 @@ import type { FastifyInstance } from "fastify"; -import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db"; -import type { LedgerEvent } from "@parking/shared"; -import { requirePermission } from "../auth.js"; +import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db"; +import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared"; +import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js"; +import { effectiveModulesFor } from "../modules.js"; import { enrichEvents } from "../event-enrich.js"; import type { EventLog } from "../event-log.js"; @@ -15,25 +16,59 @@ export async function eventRoutes( db: Db, eventLog: EventLog, ): Promise { - // Reading the log (the audit trail). - const guard = requirePermission("event:read"); + // Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it + // may still hold a module's feed permission (a wash operator's `carwash:read`) and + // then reads ONLY that module's event types — the same rule the live socket applies + // (feedPermissionFor; venue-modules.md §Permissions matrix, move 3). + + /** The event types a role may read, or null for "everything" (event:read). Empty = + * the role reads nothing → 403 at the route. */ + function readableTypes(roleId: string): LedgerEventType[] | null { + if (roleHasPermissions(roleId, ["event:read"])) return null; + const effective = effectiveModulesFor(db); + const out: LedgerEventType[] = []; + for (const m of MODULES) { + if (!m.feedPermission || !effective.includes(m.id)) continue; + if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes); + } + return out; + } + + /** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of + * the module owning the event type, else the booth. Computed in the query so the + * page limit applies AFTER the till filter (a shift's window can hold thousands of + * device events). */ + const tillExpr = (() => { + const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map( + (m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`, + ); + return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`; + })(); // Recent events, newest first. `limit` caps the page (default 100, max 1000). // Optional `since` (ISO) scopes to events at/after that instant — the booth passes // the current shift's start so the live feed shows ONLY this shift's activity. An // optional `until` (ISO) closes the upper bound — the shift-history screen passes a // selected shift's [start, end] to show just that shift's signed activity log. - // (logs are per-shift, not all history). See wiki/concepts/shift.md. - app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>( + // (logs are per-shift, not all history). An optional `till` keeps only that till's + // activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and + // vice versa. See wiki/concepts/shift.md §Tills. + app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>( "/api/events", - { preHandler: guard }, - async (req) => { + { preHandler: requireAuth }, + async (req, reply) => { + const types = readableTypes(req.user?.roleId ?? ""); + if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" }); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const since = (req.query.since ?? "").trim(); const until = (req.query.until ?? "").trim(); + const till = (req.query.till ?? "").trim(); + if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" }); const bounds = [ since ? gte(ledgerEvents.occurredAt, since) : undefined, until ? lte(ledgerEvents.occurredAt, until) : undefined, + till ? sql`${tillExpr} = ${till}` : undefined, + types ? inArray(ledgerEvents.type, types) : undefined, ].filter(Boolean); const rows = db .select() diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 449809c..51a56a0 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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: }`. 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>; + 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 = { 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 = { booth: "Bileta", carwash: "Lavazh" }; +const MODULE_PRINT_LABEL: Partial> = { 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 = { 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): Promise { - 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 { - 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 { - 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): Promise { + 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; + 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; } } diff --git a/apps/web/src/BoothScreen.tsx b/apps/web/src/BoothScreen.tsx index a214096..224bc0d 100644 --- a/apps/web/src/BoothScreen.tsx +++ b/apps/web/src/BoothScreen.tsx @@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js"; import { ActiveSessions } from "./ActiveSessions.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { EventDetailModal, EventRow } from "./ui/event-detail.js"; +import { tillOfEvent } from "@parking/shared"; // The live operator booth view — the real-time heart of the console. Occupancy // gauge + a streaming entry/exit/payment ticker. Query owns the initial load and @@ -246,7 +247,7 @@ export function BoothScreen() { const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const eventsQuery = useQuery({ queryKey: [...qk.events, shiftStart ?? "none"], - queryFn: () => fetchEvents(100, shiftStart ?? undefined), + queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"), enabled: shiftOpen, }); @@ -275,13 +276,15 @@ export function BoothScreen() { // Merge: live events first (newest), then the queried history, de-duped by id — // then clip to the current shift window (the live store spans shifts; the feed - // must not show events from before this shift's start). No shift → no feed. + // must not show events from before this shift's start) and to the BOOTH till (the + // socket also pushes wash-desk events to anyone with carwash:read; they are the wash + // shift's activity, not this one's — tillOfEvent). No shift → no feed. const seen = new Set(liveFeed.map((e) => e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const merged = [...liveFeed, ...history].slice(0, 200); const scoped = shiftOpen && shiftStart - ? merged.filter((e) => e.occurredAt >= shiftStart) + ? merged.filter((e) => e.occurredAt >= shiftStart && tillOfEvent(e.type, e.payload) === "booth") : []; // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) diff --git a/apps/web/src/DrawerManager.tsx b/apps/web/src/DrawerManager.tsx index 2b2e887..7e14fda 100644 --- a/apps/web/src/DrawerManager.tsx +++ b/apps/web/src/DrawerManager.tsx @@ -177,8 +177,8 @@ function StatePanel({ till }: { till: TillId }) { function TodayPanel({ till }: { till: TillId }) { const { t } = useTranslation(); const q = useQuery({ - queryKey: ["drawer", "today"], - queryFn: () => fetchEvents(1000, startOfToday()), + queryKey: ["drawer", "today", till], + queryFn: () => fetchEvents(1000, startOfToday(), undefined, till), refetchInterval: 15_000, }); diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx index fa96d6a..4bd66b6 100644 --- a/apps/web/src/ShiftControl.tsx +++ b/apps/web/src/ShiftControl.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js"; @@ -161,6 +161,15 @@ function CloseShiftConfirm({ <> + {/* Module money that rode the ticket (a booth-paid wash) — only when any did. */} + {Object.entries(x.chargesByModuleMinor ?? {}) + .filter(([, v]) => (v ?? 0) > 0) + .map(([m, v]) => ( + + + + + ))} {/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit' part is broken out below it; subscription SALES is not (it's the remainder). */} diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index 834b41c..62324ef 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { @@ -91,6 +91,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workabl subscriptionTotalMinor: x.subscriptionTotalMinor, subscriptionSalesMinor: x.subscriptionSalesMinor, subscriptionWindowMinor: x.subscriptionWindowMinor, + chargesByModuleMinor: x.chargesByModuleMinor, openingFloatMinor: x.openingFloatMinor, cashAddedMinor: x.cashAddedMinor, cashRemovedMinor: x.cashRemovedMinor, @@ -332,6 +333,24 @@ function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ); } +/** One figure per module whose money rode this till's tickets (a booth-paid wash) — + * nothing when none did, so booth-only sites see the report they always saw. `spacer` + * keeps a 2-column grid's pairs aligned. */ +function ChargeFigures({ charges, cur, spacer }: { charges?: Partial>; cur: string | null; spacer?: boolean }) { + const { t } = useTranslation(); + const rows = Object.entries(charges ?? {}).filter(([, v]) => (v ?? 0) > 0); + return ( + <> + {rows.map(([m, v]) => ( + +
+ {spacer && } + + ))} + + ); +} + function ShiftActivityLog({ shift, isCurrent, @@ -356,9 +375,10 @@ function ShiftActivityLog({ const [detailEvent, setDetailEvent] = useState(null); // The current shift's log runs entry→now (no upper bound); a closed shift is bounded. + // Per till: the booth's log has no wash-desk activity in it, and vice versa. const q = useQuery({ - queryKey: ["shift-events", shift.id, shift.endedAt], - queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt), + queryKey: ["shift-events", shift.id, shift.endedAt, shift.till], + queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till), refetchInterval: isCurrent ? 5000 : false, }); const events = q.data?.events ?? []; @@ -389,6 +409,7 @@ function ShiftActivityLog({
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
+
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
+ {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
@@ -472,6 +494,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
+ {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
+ {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 4b08a6b..dae0334 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1096,6 +1096,9 @@ export interface ShiftSourceSplit { subscriptionTotalMinor: number; subscriptionSalesMinor: number; subscriptionWindowMinor: number; + /** Module money that rode this till's tickets (a booth-paid wash), by module id. + * Inside cash+card, OUTSIDE the ticket bucket. Absent on pre-2026-09 reports. */ + chargesByModuleMinor?: Partial>; } export interface ShiftReport extends ShiftSourceSplit { @@ -1331,7 +1334,7 @@ export interface DeviceStatus { category: "access" | "reader" | "camera" | "printer" | "vision"; /** Role/direction token for the footer label (NOT the vendor) — the client * localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */ - roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; + roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null; state: "ready" | "degraded" | "offline"; detail?: string; checkedAt: string; @@ -1351,15 +1354,18 @@ export type { AppLogRecord }; /** Recent ledger events, newest first (default 100, max 1000). Used for the * booth feed's initial load; live updates then arrive over the WS. `since` (ISO) * scopes to events at/after that instant — the booth passes the current shift's - * start so the feed shows ONLY this shift's activity. */ + * start so the feed shows ONLY this shift's activity. `till` keeps one till's activity + * (the server applies the shared tillOfEvent rule) — a shift's log is per till. */ export function fetchEvents( limit = 100, since?: string, until?: string, + till?: TillId, ): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { const qs = new URLSearchParams({ limit: String(limit) }); if (since) qs.set("since", since); if (until) qs.set("until", until); + if (till) qs.set("till", till); return apiFetch(`/api/events?${qs.toString()}`); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 52889a3..0396577 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -232,6 +232,7 @@ export const en: Catalog = { mixed: "entry/exit", lane: "at lane", booth: "at booth", + wash: "at wash desk", }, state: { ready: "ready", @@ -960,6 +961,7 @@ export const en: Catalog = { card: "Card:", srcTickets: "Tickets:", srcSubscriptions: "Subscriptions:", + srcOnTicket: "{{module}} (on ticket):", srcSubWindow: "out-of-window", drawerSection: "— Drawer —", openingFloat: "Opening cash:", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index d5db4c5..b76ff91 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -235,6 +235,7 @@ export const sq = { mixed: "hyrje/dalje", lane: "në korsi", booth: "në kabinë", + wash: "në lavazh", }, state: { ready: "gati", @@ -974,6 +975,7 @@ export const sq = { card: "Kartë:", srcTickets: "Bileta:", srcSubscriptions: "Abonime:", + srcOnTicket: "{{module}} (në biletë):", srcSubWindow: "jashtë orarit", drawerSection: "— Arka —", openingFloat: "Arka fillestare:", diff --git a/packages/devices/src/drivers/printer-escpos.ts b/packages/devices/src/drivers/printer-escpos.ts index b9ed2de..e139f3f 100644 --- a/packages/devices/src/drivers/printer-escpos.ts +++ b/packages/devices/src/drivers/printer-escpos.ts @@ -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 diff --git a/packages/devices/src/drivers/printer-generic.ts b/packages/devices/src/drivers/printer-generic.ts index 0ff1dc7..8a5d4bc 100644 --- a/packages/devices/src/drivers/printer-generic.ts +++ b/packages/devices/src/drivers/printer-generic.ts @@ -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 = { diff --git a/packages/devices/src/drivers/printer-rongta.ts b/packages/devices/src/drivers/printer-rongta.ts index ce7d952..2415e7c 100644 --- a/packages/devices/src/drivers/printer-rongta.ts +++ b/packages/devices/src/drivers/printer-rongta.ts @@ -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 = { diff --git a/packages/devices/src/index.ts b/packages/devices/src/index.ts index 130ddb2..edf73be 100644 --- a/packages/devices/src/index.ts +++ b/packages/devices/src/index.ts @@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js"; export { orderForRole, printWithFailover, + printerRoleOf, NoPrinterAvailableError, type PrinterInstance, } from "./printer-routing.js"; diff --git a/packages/devices/src/printer-routing.test.ts b/packages/devices/src/printer-routing.test.ts index 89f2b0c..6633603 100644 --- a/packages/devices/src/printer-routing.test.ts +++ b/packages/devices/src/printer-routing.test.ts @@ -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), diff --git a/packages/devices/src/printer-routing.ts b/packages/devices/src/printer-routing.ts index 6a3e29b..4b5dcb3 100644 --- a/packages/devices/src/printer-routing.ts +++ b/packages/devices/src/printer-routing.ts @@ -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( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c0ebaed..693a998 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1926,6 +1926,17 @@ export function watchPermissions(effective: readonly ModuleId[]): Permission[] { } /** Which tills a role may work more than one of — the composer's "mixes desks" lint. */ +/** The till an event's ACTIVITY belongs to, for a shift's log: a money event names its + * till (`tillOf`); any other event belongs to the till of the module that owns its type + * (a `carwash_order` is wash-desk activity even though no money moved); everything + * else — entries, exits, barrier commands, pre-till events — is the booth's. The + * server's `/api/events?till=` filter and the web feeds share this one rule. */ +export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } | null | undefined): TillId { + if (payload?.till) return payload.till; + const m = MODULES.find((x) => x.ledgerEventTypes.includes(type)); + return m?.till ?? BOOTH_TILL; +} + export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] { return tillsFor(effective, has, "shift"); } diff --git a/wiki/concepts/printer-roles-failover.md b/wiki/concepts/printer-roles-failover.md index 14d5bc3..cc0e0d1 100644 --- a/wiki/concepts/printer-roles-failover.md +++ b/wiki/concepts/printer-roles-failover.md @@ -17,8 +17,10 @@ Each printer instance (a `devices` row, category `printer`) declares a **role** config: - **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. -- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, AND serves as the - **backup** for entry tickets. +- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, the booth till's + Z-reports and vouchers, AND serves as the **backup** for entry tickets (and for wash slips). +- **`wash-desk`** — at the Car Wash desk (added 2026-09-06). Prints the wash till's slips: + its Z-report and drawer vouchers (see [[shift]] §Tills). Nothing else ever prints here. It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple printers of the same role deterministically (ties broken by id). @@ -33,6 +35,16 @@ The reverse is **deliberately not** done: a **receipt** never prints on the outs Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no physical sense. +For a **wash slip** (`wantRole = wash-desk`): the desk printers first, then the **booth +printer** — a site that has not bought a desk printer keeps printing the wash till's Z-report +and vouchers in the booth, exactly as it did before the role existed. Nothing falls back *to* +the wash desk: a booth receipt or an entry ticket never prints there. `ShiftService` resolves +the role from the till (`TILL_PRINTER_ROLE`: booth → `booth-receipt`, carwash → `wash-desk`) +and keeps one legacy fallback — a booth with a single printer that carries no booth role still +prints its slips on it. `printerRoleOf(config)` is the one reading of the saved `role` field, +so every loader (entry flow, booth receipts, shift slips) agrees on what a printer is; the +device footer shows a desk printer as "at wash desk". + ## Where the logic lives - The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index 3d7df5c..15daf14 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -305,8 +305,25 @@ manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules: touch the booth by construction; the header button, the hub's start buttons and the drawer switch never offer a till the server would refuse. (A first cut that borrowed `session:read` as "works the booth till" lived for a few hours and is gone.) -- Not done: the per-shift *activity log* is still a time window over the whole chain (money - figures are per till, the event list is not); bay slips print on the booth printer. +- **The activity log is per till too (2026-09-06).** `tillOfEvent(type, payload)` in + `@parking/shared` extends the money rule to every event: a money event names its till, any + other event belongs to the till of the module that owns its type (a `carwash_order` is + wash-desk activity though no money moved), everything else — entries, exits, barrier + commands, pre-till events — is the booth's. `/api/events?till=` applies the same rule in + SQL (so the page limit applies after the filter); the hub's shift log, the Drawer "today" + panel and the booth feed (history and live pushes) pass their till. The events route also + admits a role without `event:read` that holds a module's feed permission, and then returns + only that module's event types — the live-socket rule, so a wash operator's hub shows the + wash shift's log. +- **The booth Z-report breaks module money out (2026-09-06).** `chargesByModuleMinor` + (`{ carwash: }`, only when any was taken) sums the `chargeLines` on the till's + payments by owning module; the ticket bucket EXCLUDES it, so `Bileta` is parking money only + and ticket + subscriptions + Σcharges = cash + card. Printed as `Lavazh (në biletë): X` on + the booth slip; the wash till's own slip prints its takings under `Lavazh:` (it sells no + tickets or subscriptions). Signed on `shift_z_report`; older reports read back as `{}`. +- **The wash till prints on its own printer (2026-09-06).** Printer role `wash-desk`; the + wash till's Z-report and vouchers go there, falling back to the booth printer — see + [[printer-roles-failover]]. ## Where the fraud control actually lives diff --git a/wiki/decisions/venue-modules.md b/wiki/decisions/venue-modules.md index a8ff654..303d30a 100644 --- a/wiki/decisions/venue-modules.md +++ b/wiki/decisions/venue-modules.md @@ -522,11 +522,12 @@ control against the unrecorded-wash vector, and it must sit with the person hold → Roles); a permission-scoped live feed for module desks (the WS is `report:read` only — the wash desk polls, 5 s / 15 s). -**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is -still a time window over the whole chain, so a booth shift's log shows wash events in that -window (money figures are per till; the log is not). A separate wash bucket on the booth's -Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth -printer until a wash-desk printer role exists. +**Follow-ups, closed 2026-09-06** (details on [[shift]] §Tills and [[printer-roles-failover]]): +the activity log is per till (`tillOfEvent`, `/api/events?till=`; a feed-permission role +reads its module's events without `event:read`); the booth Z-report carries +`chargesByModuleMinor` (a booth-paid wash is out of the ticket bucket, printed +`Lavazh (në biletë)`); the wash till's slips print on a `wash-desk` printer, falling back +to the booth's. ## Review log — issues and ideas from the first hands-on pass (2026-09-05) diff --git a/wiki/log.md b/wiki/log.md index a4e5b1b..b869d5b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -3049,3 +3049,24 @@ design error: the discount ENGINE (validation program rows + `applyValidation()` `validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void) stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built). + +## [2026-09-06] ingest | Tills follow-ups closed: per-till activity log, wash bucket on the Z, wash-desk printer +The three "known follow-ups" of the Tills decision are built. (1) `tillOfEvent(type, payload)` +in `@parking/shared` — money events by payload `till`, other events by their owning module's +till, everything else booth — is applied by `/api/events?till=` in SQL and passed by the hub +log, the Drawer "today" panel and the booth feed; the events route now admits module-feed +roles (a wash operator's `carwash:read`) and returns only their module's types, the same rule +the live socket uses. (2) `chargesByModuleMinor` on the shift report/summary/signed payload: +module charges on the till's payments by module; the ticket bucket excludes them; printed +`Lavazh (në biletë)` on the booth slip; the wash till's slip prints `Lavazh:` for its own +takings. (3) Printer role `wash-desk`: the wash till's Z-report and vouchers print there with +failover to the booth printer; `printerRoleOf()` is the one reading of the role field so a +desk printer is never mistaken for an entry dispenser; footer label "at wash desk". Updated +[[shift]] §Tills, [[printer-roles-failover]], [[venue-modules]]. + +## [2026-09-06] ingest | Wash operator job could not load the desk's price list +User built a role from the "Wash operator" chip (carwash:read/create/update/cash) and the desk's +category/service pickers stayed empty. Cause: `GET /api/carwash/settings` was guarded by +`site:read` only — the price list is Setup's data AND the desk's working data. Fixed with a +new `requireAnyPermission(...)` guard (auth.ts): the read opens to `carwash:read` OR +`site:read`; the write stays `site:update`. Regression test in carwash.test.ts.