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
+16
View File
@@ -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 — * preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may * for "about me" routes (/me, change own language) every authenticated user may
+2 -1
View File
@@ -6,6 +6,7 @@ import {
type PrinterInstance, type PrinterInstance,
type ReceiptData, type ReceiptData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js"; import { devicesByDirection } from "./device-resolve.js";
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+3 -3
View File
@@ -52,7 +52,7 @@ export interface ReadOutcome {
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // devices id readonly deviceId: string; // devices id
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
@@ -74,10 +74,10 @@ export interface DeviceStatusEvent {
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina": * chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay) * - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[]) * - 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) * - 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 state: "ready" | "degraded" | "offline";
readonly detail?: string; readonly detail?: string;
readonly checkedAt: string; // ISO-8601 readonly checkedAt: string; // ISO-8601
+2 -1
View File
@@ -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) * - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more * - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet * 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"] { function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) { switch (row.category) {
@@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
const role = (row.config as { role?: string }).role; const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth"; if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane"; if (role === "entry-dispenser") return "lane";
if (role === "wash-desk") return "wash";
return null; return null;
} }
default: default:
+2 -1
View File
@@ -9,6 +9,7 @@ import {
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -523,7 +524,7 @@ export class EntryFlow {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
@@ -188,6 +188,17 @@ describe("orders", () => {
// A second lookup no longer carries the line (it's settled). // 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 } }); const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
expect(again.json().chargeLines).toEqual([]); 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 () => { 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"], permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
}); });
const w = await login(app, washer.username, washer.password); 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. // What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } }); 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"]); 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"); 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<string, unknown> }[]).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);
});
});
+4 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import type { Tender } from "@parking/shared"; import type { Tender } from "@parking/shared";
import { requirePermission } from "../../auth.js"; import { requireAnyPermission, requirePermission } from "../../auth.js";
import { requireModule } from "../../modules.js"; import { requireModule } from "../../modules.js";
import { NoShiftOpenError } from "../../shift-service.js"; import { NoShiftOpenError } from "../../shift-service.js";
import type { ServerModuleDeps } from "../index.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<void> { export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
const moduleOn = requireModule(deps.db, "carwash"); 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 settingsWrite = [moduleOn, requirePermission("site:update")];
const read = [moduleOn, requirePermission("carwash:read")]; const read = [moduleOn, requirePermission("carwash:read")];
const create = [moduleOn, requirePermission("carwash:create")]; const create = [moduleOn, requirePermission("carwash:create")];
+44 -9
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db"; import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { enrichEvents } from "../event-enrich.js"; import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -15,25 +16,59 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Reading the log (the audit trail). // Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
const guard = requirePermission("event:read"); // 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). // 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 // 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 // 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 // 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. // 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. // (logs are per-shift, not all history). An optional `till` keeps only that till's
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>( // 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", "/api/events",
{ preHandler: guard }, { preHandler: requireAuth },
async (req) => { 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 limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const since = (req.query.since ?? "").trim(); const since = (req.query.since ?? "").trim();
const until = (req.query.until ?? "").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 = [ const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined, since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined, until ? lte(ledgerEvents.occurredAt, until) : undefined,
till ? sql`${tillExpr} = ${till}` : undefined,
types ? inArray(ledgerEvents.type, types) : undefined,
].filter(Boolean); ].filter(Boolean);
const rows = db const rows = db
.select() .select()
+111 -48
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db"; import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices"; import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared"; import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -71,12 +71,19 @@ export interface ShiftSummary {
readonly subscriptionSalesMinor: number; readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number; readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number; readonly discountTotalMinor: number;
readonly chargesByModuleMinor: ChargesByModule;
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
readonly cashAddedMinor: number; readonly cashAddedMinor: number;
readonly cashRemovedMinor: number; readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: 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 { export interface ShiftReport {
readonly till: TillId; readonly till: TillId;
readonly operator: string; readonly operator: string;
@@ -98,6 +105,8 @@ export interface ShiftReport {
/** Merchant-validation DISCOUNT total given away in the window (leakage — the /** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */ * cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number; 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) --- // --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */ /** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
@@ -142,6 +151,15 @@ export class InvalidCashMovementError extends Error {
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */ /** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" }; 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 { export class ShiftService {
readonly #db: Db; readonly #db: Db;
@@ -248,6 +266,7 @@ export class ShiftService {
subscriptionSalesMinor?: number; subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number; subscriptionWindowMinor?: number;
discountTotalMinor?: number; discountTotalMinor?: number;
chargesByModuleMinor?: ChargesByModule;
openingFloatMinor?: number; openingFloatMinor?: number;
cashAddedMinor?: number; cashAddedMinor?: number;
cashRemovedMinor?: number; cashRemovedMinor?: number;
@@ -283,6 +302,8 @@ export class ShiftService {
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0), (pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0. // Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 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, openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0, cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0, cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -558,8 +579,8 @@ export class ShiftService {
.from(ledgerEvents) .from(ledgerEvents)
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the // 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 // 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 // tender totals so the expected drawer is right; the booth-paid wash is then
// Z-report is a follow-up (venue-modules.md). // broken OUT of the ticket bucket into chargesByModuleMinor (see below).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"])) .where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all() .all()
.filter( .filter(
@@ -578,11 +599,16 @@ export class ShiftService {
// Merchant-validation leakage: Σ discountMinor across the window's payments. The // Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them. // tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0; 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; let currency: string | null = null;
for (const p of payments) { for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & { const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean; subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean; subscriptionWindowCharge?: boolean;
chargeLines?: ChargeLine[];
}; };
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt; if (pl.tender === "card") cardTotalMinor += amt;
@@ -591,10 +617,17 @@ export class ShiftService {
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt; else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription) // (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor; 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; if (pl.currency) currency = pl.currency;
} }
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor; 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 --- // --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start); // Opening float was fixed on shift_open (inherited from the chain at start);
@@ -649,6 +682,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -689,6 +723,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -713,6 +748,8 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
// Only when a module charged in the window (older slips/payloads stay identical).
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -729,14 +766,9 @@ export class ShiftService {
return { ...report, printed }; return { ...report, printed };
} }
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event /** Print the Z-report on the till's printer (best-effort; the signed event is the
* is the record — a failed print doesn't undo the close). */ * record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> { 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 cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not // 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}`, `Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"", "",
"-- Arkëtime sipas burimit --", "-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`, // The booth prints its three classic lines (byte-identical to before tills); a
// Abonime is the subscription TOTAL; only the out-of-window part is broken out. // module's till prints its own takings under its own name — it sells no tickets
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.) // and no subscriptions.
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, ...(r.till === BOOTH_TILL
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, ? [
`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 // Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it. // (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []), ...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
@@ -770,13 +814,7 @@ export class ShiftService {
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`, `Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`, `Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
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;
}
} }
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort — /** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
@@ -792,11 +830,6 @@ export class ShiftService {
at: string; at: string;
till: TillId; till: TillId;
}): Promise<boolean> { }): 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 cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE"; const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
@@ -810,27 +843,57 @@ export class ShiftService {
"", "",
`Regjistroi: ${v.operator}`, `Regjistroi: ${v.operator}`,
]; ];
try { return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
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;
}
} }
/** First enabled booth-receipt printer, or any enabled printer. */ /** Print a till's slip on its printer with failover (wash desk → booth printer;
async #boothPrinter(): Promise<PrinterDevice | null> { * see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all(); * record — every failure is logged and reported as "not printed", never thrown.
const enabled = rows.filter((r) => r.enabled); * Legacy fallback: a site whose only printer carries no booth role (one unit,
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; * configured as the entry dispenser) still prints its slips on it, as before. */
if (!booth) return null; async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
const driver = registry.get(booth.driverId); const printers = this.#loadPrinters();
if (!driver) return null; const want = TILL_PRINTER_ROLE[till];
try { let ordered = orderForRole(printers, want);
return driver.create(booth.config as never) as PrinterDevice; if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
} catch { if (ordered.length === 0) {
return null; 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;
} }
} }
+6 -3
View File
@@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js"; import { ActiveSessions } from "./ActiveSessions.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.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 // 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 // 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 occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"], queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined), queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"),
enabled: shiftOpen, enabled: shiftOpen,
}); });
@@ -275,13 +276,15 @@ export function BoothScreen() {
// Merge: live events first (newest), then the queried history, de-duped by id — // 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 // 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 seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200); const merged = [...liveFeed, ...history].slice(0, 200);
const scoped = const scoped =
shiftOpen && shiftStart 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`) // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
+2 -2
View File
@@ -177,8 +177,8 @@ function StatePanel({ till }: { till: TillId }) {
function TodayPanel({ till }: { till: TillId }) { function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "today"], queryKey: ["drawer", "today", till],
queryFn: () => fetchEvents(1000, startOfToday()), queryFn: () => fetchEvents(1000, startOfToday(), undefined, till),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
+10 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js"; import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
@@ -161,6 +161,15 @@ function CloseShiftConfirm({
<> <>
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} /> <ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} /> <ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* 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]) => (
<Fragment key={m}>
<ConfirmFigure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={fmt(v ?? 0)} />
<span />
</Fragment>
))}
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit' {/* 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). */} part is broken out below it; subscription SALES is not (it's the remainder). */}
<span /> <span />
+27 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
@@ -91,6 +91,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workabl
subscriptionTotalMinor: x.subscriptionTotalMinor, subscriptionTotalMinor: x.subscriptionTotalMinor,
subscriptionSalesMinor: x.subscriptionSalesMinor, subscriptionSalesMinor: x.subscriptionSalesMinor,
subscriptionWindowMinor: x.subscriptionWindowMinor, subscriptionWindowMinor: x.subscriptionWindowMinor,
chargesByModuleMinor: x.chargesByModuleMinor,
openingFloatMinor: x.openingFloatMinor, openingFloatMinor: x.openingFloatMinor,
cashAddedMinor: x.cashAddedMinor, cashAddedMinor: x.cashAddedMinor,
cashRemovedMinor: x.cashRemovedMinor, 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<Record<string, number>>; cur: string | null; spacer?: boolean }) {
const { t } = useTranslation();
const rows = Object.entries(charges ?? {}).filter(([, v]) => (v ?? 0) > 0);
return (
<>
{rows.map(([m, v]) => (
<Fragment key={m}>
<Figure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={money(v ?? 0, cur)} />
{spacer && <span />}
</Fragment>
))}
</>
);
}
function ShiftActivityLog({ function ShiftActivityLog({
shift, shift,
isCurrent, isCurrent,
@@ -356,9 +375,10 @@ function ShiftActivityLog({
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null); const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded. // 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({ const q = useQuery({
queryKey: ["shift-events", shift.id, shift.endedAt], queryKey: ["shift-events", shift.id, shift.endedAt, shift.till],
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt), queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till),
refetchInterval: isCurrent ? 5000 : false, refetchInterval: isCurrent ? 5000 : false,
}); });
const events = q.data?.events ?? []; const events = q.data?.events ?? [];
@@ -389,6 +409,7 @@ function ShiftActivityLog({
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} /> <Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} /> <Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} /> <Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} /> <Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<ChargeFigures charges={report.chargesByModuleMinor} cur={report.currency} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
@@ -472,6 +494,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5"> <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} /> <Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} /> <Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} /> <Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<ChargeFigures charges={x.chargesByModuleMinor} cur={x.currency} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
+8 -2
View File
@@ -1096,6 +1096,9 @@ export interface ShiftSourceSplit {
subscriptionTotalMinor: number; subscriptionTotalMinor: number;
subscriptionSalesMinor: number; subscriptionSalesMinor: number;
subscriptionWindowMinor: 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<Record<string, number>>;
} }
export interface ShiftReport extends ShiftSourceSplit { export interface ShiftReport extends ShiftSourceSplit {
@@ -1331,7 +1334,7 @@ export interface DeviceStatus {
category: "access" | "reader" | "camera" | "printer" | "vision"; category: "access" | "reader" | "camera" | "printer" | "vision";
/** Role/direction token for the footer label (NOT the vendor) — the client /** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */ * 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"; state: "ready" | "degraded" | "offline";
detail?: string; detail?: string;
checkedAt: string; checkedAt: string;
@@ -1351,15 +1354,18 @@ export type { AppLogRecord };
/** Recent ledger events, newest first (default 100, max 1000). Used for the /** 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) * 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 * 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( export function fetchEvents(
limit = 100, limit = 100,
since?: string, since?: string,
until?: string, until?: string,
till?: TillId,
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { ): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
const qs = new URLSearchParams({ limit: String(limit) }); const qs = new URLSearchParams({ limit: String(limit) });
if (since) qs.set("since", since); if (since) qs.set("since", since);
if (until) qs.set("until", until); if (until) qs.set("until", until);
if (till) qs.set("till", till);
return apiFetch(`/api/events?${qs.toString()}`); return apiFetch(`/api/events?${qs.toString()}`);
} }
+2
View File
@@ -232,6 +232,7 @@ export const en: Catalog = {
mixed: "entry/exit", mixed: "entry/exit",
lane: "at lane", lane: "at lane",
booth: "at booth", booth: "at booth",
wash: "at wash desk",
}, },
state: { state: {
ready: "ready", ready: "ready",
@@ -960,6 +961,7 @@ export const en: Catalog = {
card: "Card:", card: "Card:",
srcTickets: "Tickets:", srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:", srcSubscriptions: "Subscriptions:",
srcOnTicket: "{{module}} (on ticket):",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
drawerSection: "— Drawer —", drawerSection: "— Drawer —",
openingFloat: "Opening cash:", openingFloat: "Opening cash:",
+2
View File
@@ -235,6 +235,7 @@ export const sq = {
mixed: "hyrje/dalje", mixed: "hyrje/dalje",
lane: "në korsi", lane: "në korsi",
booth: "në kabinë", booth: "në kabinë",
wash: "në lavazh",
}, },
state: { state: {
ready: "gati", ready: "gati",
@@ -974,6 +975,7 @@ export const sq = {
card: "Kartë:", card: "Kartë:",
srcTickets: "Bileta:", srcTickets: "Bileta:",
srcSubscriptions: "Abonime:", srcSubscriptions: "Abonime:",
srcOnTicket: "{{module}} (në biletë):",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —", drawerSection: "— Arka —",
openingFloat: "Arka fillestare:", openingFloat: "Arka fillestare:",
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
// --- shared driver config fields ---------------------------------------------- // --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each // Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md. // 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) --------------------------------- // --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each // 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)", label: "Entry dispenser (outside / at the lane)",
}, },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { 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 = { const rankField: ConfigField = {
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
} }
/** Type guard: does this device carry a printer role (entry vs. booth)? */ /** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
export type PrinterRole = "entry-dispenser" | "booth-receipt"; * 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 = { const roleField: ConfigField = {
key: "role", key: "role",
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
label: "Entry dispenser (outside / at the lane)", label: "Entry dispenser (outside / at the lane)",
}, },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { 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 = { const rankField: ConfigField = {
+1
View File
@@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
export { export {
orderForRole, orderForRole,
printWithFailover, printWithFailover,
printerRoleOf,
NoPrinterAvailableError, NoPrinterAvailableError,
type PrinterInstance, type PrinterInstance,
} from "./printer-routing.js"; } from "./printer-routing.js";
@@ -27,6 +27,19 @@ describe("orderForRole", () => {
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]); 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", () => { it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [ const printers = [
inst("b", "entry-dispenser", 1), inst("b", "entry-dispenser", 1),
+12 -1
View File
@@ -26,13 +26,16 @@ export interface PrinterInstance {
* printer is also a fallback for entry tickets, so when an entry ticket is * 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 * routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser. * 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( export function orderForRole(
printers: readonly PrinterInstance[], printers: readonly PrinterInstance[],
wantRole: PrinterRole, wantRole: PrinterRole,
): PrinterInstance[] { ): PrinterInstance[] {
const fallbackRole: PrinterRole | null = const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" ? "booth-receipt" : null; wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => { const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2; 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 { export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) { constructor(public readonly attempts: { id: string; error: string }[]) {
super( super(
+11
View File
@@ -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. */ /** 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[] { export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift"); return tillsFor(effective, has, "shift");
} }
+14 -2
View File
@@ -17,8 +17,10 @@ Each printer instance (a `devices` row, category `printer`) declares a **role**
config: config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. - **`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 - **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, the booth till's
**backup** for entry tickets. 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 It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple
printers of the same role deterministically (ties broken by id). 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 Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no
physical sense. 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 ## Where the logic lives
- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't - The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't
+19 -2
View File
@@ -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 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 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.) `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 - **The activity log is per till too (2026-09-06).** `tillOfEvent(type, payload)` in
figures are per till, the event list is not); bay slips print on the booth printer. `@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: <minor> }`, 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 ## Where the fraud control actually lives
+6 -5
View File
@@ -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 — → Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
the wash desk polls, 5 s / 15 s). the wash desk polls, 5 s / 15 s).
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is **Follow-ups, closed 2026-09-06** (details on [[shift]] §Tills and [[printer-roles-failover]]):
still a time window over the whole chain, so a booth shift's log shows wash events in that the activity log is per till (`tillOfEvent`, `/api/events?till=`; a feed-permission role
window (money figures are per till; the log is not). A separate wash bucket on the booth's reads its module's events without `event:read`); the booth Z-report carries
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth `chargesByModuleMinor` (a booth-paid wash is out of the ticket bucket, printed
printer until a wash-desk printer role exists. `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) ## Review log — issues and ideas from the first hands-on pass (2026-09-05)
+21
View File
@@ -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 `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) 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). 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.