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