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:
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
const role = printerRoleOf(cfg);
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
||||
const role = printerRoleOf(cfg);
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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")];
|
||||
|
||||
@@ -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<void> {
|
||||
// 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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
|
||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||||
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared";
|
||||
import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
|
||||
import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
@@ -71,12 +71,19 @@ export interface ShiftSummary {
|
||||
readonly subscriptionSalesMinor: number;
|
||||
readonly subscriptionWindowMinor: number;
|
||||
readonly discountTotalMinor: number;
|
||||
readonly chargesByModuleMinor: ChargesByModule;
|
||||
readonly openingFloatMinor: number;
|
||||
readonly cashAddedMinor: number;
|
||||
readonly cashRemovedMinor: number;
|
||||
readonly expectedDrawerMinor: number;
|
||||
}
|
||||
|
||||
/** Module money folded into this till's payments as `chargeLines`, by owning module —
|
||||
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
|
||||
* that actually charged in the window appear. Cash+card already contain it; it is
|
||||
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
|
||||
export type ChargesByModule = Partial<Record<ModuleId, number>>;
|
||||
|
||||
export interface ShiftReport {
|
||||
readonly till: TillId;
|
||||
readonly operator: string;
|
||||
@@ -98,6 +105,8 @@ export interface ShiftReport {
|
||||
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||
readonly discountTotalMinor: number;
|
||||
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
|
||||
readonly chargesByModuleMinor: ChargesByModule;
|
||||
// --- Drawer (physical cash till; carries across shifts) ---
|
||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||
readonly openingFloatMinor: number;
|
||||
@@ -142,6 +151,15 @@ export class InvalidCashMovementError extends Error {
|
||||
|
||||
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
|
||||
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
|
||||
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
|
||||
* desk sells washes) and the label a module's charge gets when it rides another
|
||||
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
|
||||
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
|
||||
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
|
||||
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
|
||||
* the booth printer when it has none of its own (orderForRole); the booth never falls
|
||||
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
|
||||
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
|
||||
|
||||
export class ShiftService {
|
||||
readonly #db: Db;
|
||||
@@ -248,6 +266,7 @@ export class ShiftService {
|
||||
subscriptionSalesMinor?: number;
|
||||
subscriptionWindowMinor?: number;
|
||||
discountTotalMinor?: number;
|
||||
chargesByModuleMinor?: ChargesByModule;
|
||||
openingFloatMinor?: number;
|
||||
cashAddedMinor?: number;
|
||||
cashRemovedMinor?: number;
|
||||
@@ -283,6 +302,8 @@ export class ShiftService {
|
||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
||||
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
|
||||
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
|
||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||
@@ -558,8 +579,8 @@ export class ShiftService {
|
||||
.from(ledgerEvents)
|
||||
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
|
||||
// parking payment's amount already, as chargeLines). Both fold into the cash/card
|
||||
// tender totals so the expected drawer is right; a separate wash bucket on the
|
||||
// Z-report is a follow-up (venue-modules.md).
|
||||
// tender totals so the expected drawer is right; the booth-paid wash is then
|
||||
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
|
||||
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
|
||||
.all()
|
||||
.filter(
|
||||
@@ -578,11 +599,16 @@ export class ShiftService {
|
||||
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||
// tender totals are already NET; this is the "given away" figure beside them.
|
||||
let discountTotalMinor = 0;
|
||||
// Module charges folded into this till's payments (chargeLines on a booth payment),
|
||||
// summed by owning module. Part of cash/card; NOT ticket money.
|
||||
const chargesByModuleMinor: ChargesByModule = {};
|
||||
let chargesTotalMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const p of payments) {
|
||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||
subscriptionSale?: boolean;
|
||||
subscriptionWindowCharge?: boolean;
|
||||
chargeLines?: ChargeLine[];
|
||||
};
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (pl.tender === "card") cardTotalMinor += amt;
|
||||
@@ -591,10 +617,17 @@ export class ShiftService {
|
||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||
// (else → transient ticket; derived below as total − subscription)
|
||||
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||
for (const l of pl.chargeLines ?? []) {
|
||||
if (typeof l.amountMinor !== "number" || !l.module) continue;
|
||||
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
|
||||
chargesTotalMinor += l.amountMinor;
|
||||
}
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
||||
// Ticket = what is left once subscriber money and module charges are taken out:
|
||||
// ticket + subscriptions + Σcharges = cash + card, always.
|
||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
|
||||
|
||||
// --- Drawer figures ---
|
||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||
@@ -649,6 +682,7 @@ export class ShiftService {
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
chargesByModuleMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -689,6 +723,7 @@ export class ShiftService {
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
chargesByModuleMinor,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -713,6 +748,8 @@ export class ShiftService {
|
||||
subscriptionSalesMinor,
|
||||
subscriptionWindowMinor,
|
||||
discountTotalMinor,
|
||||
// Only when a module charged in the window (older slips/payloads stay identical).
|
||||
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -729,14 +766,9 @@ export class ShiftService {
|
||||
return { ...report, printed };
|
||||
}
|
||||
|
||||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||
* is the record — a failed print doesn't undo the close). */
|
||||
/** Print the Z-report on the till's printer (best-effort; the signed event is the
|
||||
* record — a failed print doesn't undo the close). */
|
||||
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = r.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||
@@ -754,11 +786,23 @@ export class ShiftService {
|
||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Arkëtime sipas burimit --",
|
||||
// The booth prints its three classic lines (byte-identical to before tills); a
|
||||
// module's till prints its own takings under its own name — it sells no tickets
|
||||
// and no subscriptions.
|
||||
...(r.till === BOOTH_TILL
|
||||
? [
|
||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||
]
|
||||
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
|
||||
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
|
||||
// only when any was taken, so the operator sees parking and wash money apart.
|
||||
...Object.entries(r.chargesByModuleMinor)
|
||||
.filter(([, v]) => (v ?? 0) > 0)
|
||||
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
|
||||
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||
@@ -770,13 +814,7 @@ export class ShiftService {
|
||||
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
|
||||
}
|
||||
|
||||
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
||||
@@ -792,11 +830,6 @@ export class ShiftService {
|
||||
at: string;
|
||||
till: TillId;
|
||||
}): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
const cur = v.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||
@@ -810,27 +843,57 @@ export class ShiftService {
|
||||
"",
|
||||
`Regjistroi: ${v.operator}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title, lines });
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
|
||||
}
|
||||
|
||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const enabled = rows.filter((r) => r.enabled);
|
||||
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
||||
if (!booth) return null;
|
||||
const driver = registry.get(booth.driverId);
|
||||
if (!driver) return null;
|
||||
try {
|
||||
return driver.create(booth.config as never) as PrinterDevice;
|
||||
} catch {
|
||||
return null;
|
||||
/** Print a till's slip on its printer with failover (wash desk → booth printer;
|
||||
* see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
|
||||
* record — every failure is logged and reported as "not printed", never thrown.
|
||||
* Legacy fallback: a site whose only printer carries no booth role (one unit,
|
||||
* configured as the entry dispenser) still prints its slips on it, as before. */
|
||||
async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
|
||||
const printers = this.#loadPrinters();
|
||||
const want = TILL_PRINTER_ROLE[till];
|
||||
let ordered = orderForRole(printers, want);
|
||||
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
|
||||
if (ordered.length === 0) {
|
||||
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
|
||||
return false;
|
||||
}
|
||||
const attempts: string[] = [];
|
||||
for (const p of ordered) {
|
||||
try {
|
||||
await job(p.device);
|
||||
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
attempts.push(`${p.id} (${(err as Error).message})`);
|
||||
}
|
||||
}
|
||||
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every enabled printer as a live instance (role + rank from its saved config). */
|
||||
#loadPrinters(): PrinterInstance[] {
|
||||
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||
const out: PrinterInstance[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.enabled) continue;
|
||||
const driver = registry.get(row.driverId);
|
||||
if (!driver) continue;
|
||||
const cfg = row.config as Record<string, unknown>;
|
||||
try {
|
||||
out.push({
|
||||
id: row.id,
|
||||
role: printerRoleOf(cfg),
|
||||
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||
device: driver.create(cfg as never) as PrinterDevice,
|
||||
});
|
||||
} catch {
|
||||
// skip a printer whose config won't build
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<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'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
|
||||
@@ -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<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({
|
||||
shift,
|
||||
isCurrent,
|
||||
@@ -356,9 +375,10 @@ function ShiftActivityLog({
|
||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(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({
|
||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||
{/* 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 />
|
||||
<ChargeFigures charges={shift.chargesByModuleMinor} cur={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.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, 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. */}
|
||||
<span />
|
||||
<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">
|
||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, 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. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
|
||||
<span />
|
||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, 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. */}
|
||||
<span />
|
||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||
|
||||
+8
-2
@@ -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<Record<string, number>>;
|
||||
}
|
||||
|
||||
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()}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
|
||||
// --- shared driver config fields ----------------------------------------------
|
||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
||||
|
||||
// --- shared printer config fields (transport) ---------------------------------
|
||||
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
||||
|
||||
@@ -128,8 +128,9 @@ const roleField: ConfigField = {
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
||||
};
|
||||
|
||||
const rankField: ConfigField = {
|
||||
|
||||
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/** Type guard: does this device carry a printer role (entry vs. booth)? */
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
||||
/** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
|
||||
* the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */
|
||||
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
||||
|
||||
const roleField: ConfigField = {
|
||||
key: "role",
|
||||
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
|
||||
label: "Entry dispenser (outside / at the lane)",
|
||||
},
|
||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
||||
],
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
||||
};
|
||||
|
||||
const rankField: ConfigField = {
|
||||
|
||||
@@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
|
||||
export {
|
||||
orderForRole,
|
||||
printWithFailover,
|
||||
printerRoleOf,
|
||||
NoPrinterAvailableError,
|
||||
type PrinterInstance,
|
||||
} from "./printer-routing.js";
|
||||
|
||||
@@ -27,6 +27,19 @@ describe("orderForRole", () => {
|
||||
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
|
||||
});
|
||||
|
||||
it("wash-desk job: the desk printer first, the booth printer as fallback, never the dispenser", () => {
|
||||
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt"), inst("desk", "wash-desk")];
|
||||
expect(orderForRole(printers, "wash-desk").map((p) => p.id)).toEqual(["desk", "booth"]);
|
||||
// A site without a desk printer keeps printing wash slips in the booth.
|
||||
expect(orderForRole(printers.slice(0, 2), "wash-desk").map((p) => p.id)).toEqual(["booth"]);
|
||||
});
|
||||
|
||||
it("nothing ever falls back TO the wash desk (booth receipts and entry tickets stay off it)", () => {
|
||||
const printers = [inst("desk", "wash-desk")];
|
||||
expect(orderForRole(printers, "booth-receipt")).toEqual([]);
|
||||
expect(orderForRole(printers, "entry-dispenser")).toEqual([]);
|
||||
});
|
||||
|
||||
it("breaks ties by failoverRank (higher first), then id", () => {
|
||||
const printers = [
|
||||
inst("b", "entry-dispenser", 1),
|
||||
|
||||
@@ -26,13 +26,16 @@ export interface PrinterInstance {
|
||||
* printer is also a fallback for entry tickets, so when an entry ticket is
|
||||
* routed, booth-receipt printers follow the entry dispensers. The reverse is
|
||||
* deliberately NOT done — a receipt never prints on the outside dispenser.
|
||||
* The wash desk's slips (its till's Z-report and vouchers) fall back to the booth
|
||||
* printer the same way — a site without a desk printer keeps printing them in the
|
||||
* booth, as it did before the role existed. Nothing ever falls back TO the wash desk.
|
||||
*/
|
||||
export function orderForRole(
|
||||
printers: readonly PrinterInstance[],
|
||||
wantRole: PrinterRole,
|
||||
): PrinterInstance[] {
|
||||
const fallbackRole: PrinterRole | null =
|
||||
wantRole === "entry-dispenser" ? "booth-receipt" : null;
|
||||
wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
|
||||
|
||||
const rank = (p: PrinterInstance): number => {
|
||||
if (p.role === wantRole) return 2;
|
||||
@@ -49,6 +52,14 @@ export function orderForRole(
|
||||
});
|
||||
}
|
||||
|
||||
/** The role a printer's saved config declares — ONE reading of the field, so a
|
||||
* wash-desk printer is never mistaken for an entry dispenser by a loader that only
|
||||
* knew two roles. Unknown/absent = entry-dispenser (the field's default). */
|
||||
export function printerRoleOf(cfg: { role?: unknown } | null | undefined): PrinterRole {
|
||||
const r = cfg?.role;
|
||||
return r === "booth-receipt" || r === "wash-desk" ? r : "entry-dispenser";
|
||||
}
|
||||
|
||||
export class NoPrinterAvailableError extends Error {
|
||||
constructor(public readonly attempts: { id: string; error: string }[]) {
|
||||
super(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-2
@@ -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: <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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+21
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user