feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
|
||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
|
||||
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||
// `payment` events taken during the shift by tender and print a Z-report.
|
||||
//
|
||||
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
|
||||
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
|
||||
// its own operator, opening float, expected drawer and Z-report. Every money event
|
||||
// names its till (`payload.till`; absent = booth, which is what every pre-till event
|
||||
// is), and every fold in this file filters by it. Every public method takes the till,
|
||||
// defaulting to the booth so the parking paths read as they always did.
|
||||
// See wiki/concepts/shift.md.
|
||||
|
||||
export class ShiftAlreadyOpenError extends Error {
|
||||
/** The operator who currently holds the open shift (may be someone else). */
|
||||
readonly heldBy: string;
|
||||
constructor(operator: string, heldBy: string) {
|
||||
constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
|
||||
super(
|
||||
heldBy === operator
|
||||
? `operator ${operator} already has an open shift`
|
||||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
||||
? `operator ${operator} already has an open ${till} shift`
|
||||
: `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
|
||||
);
|
||||
this.name = "ShiftAlreadyOpenError";
|
||||
this.heldBy = heldBy;
|
||||
}
|
||||
}
|
||||
export class NoOpenShiftError extends Error {
|
||||
constructor(operator: string) {
|
||||
super(`operator ${operator} has no open shift`);
|
||||
constructor(operator: string, till: TillId = BOOTH_TILL) {
|
||||
super(`operator ${operator} has no open ${till} shift`);
|
||||
this.name = "NoOpenShiftError";
|
||||
}
|
||||
}
|
||||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
||||
* must open a shift before any payment/exit can be attributed to a shift. */
|
||||
/** Thrown by a money path when NO shift is open on its till — an operator must open
|
||||
* a shift there before any payment/exit can be attributed to one. */
|
||||
export class NoShiftOpenError extends Error {
|
||||
constructor() {
|
||||
super("no shift is open — open a shift before processing tickets");
|
||||
readonly till: TillId;
|
||||
constructor(till: TillId = BOOTH_TILL) {
|
||||
super(
|
||||
till === BOOTH_TILL
|
||||
? "no shift is open — open a shift before processing tickets"
|
||||
: `no ${till} shift is open — open one before taking money there`,
|
||||
);
|
||||
this.name = "NoShiftOpenError";
|
||||
this.till = till;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
|
||||
export interface ShiftSummary {
|
||||
readonly id: string;
|
||||
readonly index: number;
|
||||
/** The till this shift reconciled (booth for every pre-till report). */
|
||||
readonly till: TillId;
|
||||
readonly operator: string;
|
||||
readonly startedAt: string;
|
||||
readonly endedAt: string;
|
||||
@@ -63,6 +78,7 @@ export interface ShiftSummary {
|
||||
}
|
||||
|
||||
export interface ShiftReport {
|
||||
readonly till: TillId;
|
||||
readonly operator: string;
|
||||
readonly startedAt: string;
|
||||
readonly endedAt: string;
|
||||
@@ -102,6 +118,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
export interface DrawerMovement {
|
||||
readonly id: string;
|
||||
readonly type: "cash_in" | "cash_out";
|
||||
/** Which drawer the cash moved in/out of. */
|
||||
readonly till: TillId;
|
||||
/** Positive magnitude; direction is the `type`. */
|
||||
readonly amountMinor: number;
|
||||
readonly currency: string | null;
|
||||
@@ -122,6 +140,9 @@ 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" };
|
||||
|
||||
export class ShiftService {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
@@ -133,41 +154,42 @@ export class ShiftService {
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
||||
* the UI to show "inherited / in the drawer now". */
|
||||
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
||||
return this.#drawerBalanceAt(new Date().toISOString());
|
||||
/** Current physical drawer balance of a till (cash payments + cash_movements, by
|
||||
* time). For the UI to show "inherited / in the drawer now". */
|
||||
drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
|
||||
return this.#drawerBalanceAt(new Date().toISOString(), till);
|
||||
}
|
||||
|
||||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||
openShiftFor(operator: string) {
|
||||
// Scan shift events for this operator; the shift is open if the most recent
|
||||
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
||||
const rows = this.#db
|
||||
/** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
|
||||
#shiftEvents(till: TillId) {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, operator))
|
||||
.where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||
.filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
|
||||
}
|
||||
|
||||
/** Is there an open shift for this operator on this till? Returns the open
|
||||
* `shift_open` row or null. */
|
||||
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
|
||||
// The shift is open if the operator's most recent shift event on the till is a
|
||||
// `shift_open` (not yet closed by a z_report).
|
||||
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
|
||||
const last = rows[rows.length - 1];
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
||||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
||||
* attributed to one operator). It's open iff the most recent shift event on the
|
||||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
||||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
||||
* The SINGLE open shift of a till, or null. A shift is the till's accountability
|
||||
* period: at most ONE may be open per till at a time (so its takings are
|
||||
* unambiguously attributed to one operator). It's open iff the till's most recent
|
||||
* shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
|
||||
* yet). Returns that row so callers can read its operator/startedAt.
|
||||
*/
|
||||
currentOpenShift() {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all()
|
||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||||
currentOpenShift(till: TillId = BOOTH_TILL) {
|
||||
const rows = this.#shiftEvents(till);
|
||||
const last = rows[rows.length - 1];
|
||||
return last && last.type === "shift_open" ? last : null;
|
||||
}
|
||||
@@ -186,24 +208,25 @@ export class ShiftService {
|
||||
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||
* for an operator that exists (the filter is an exact username match).
|
||||
*/
|
||||
listOperators(): string[] {
|
||||
listOperators(till?: TillId): string[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||||
.all();
|
||||
.where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
|
||||
.all()
|
||||
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
|
||||
// Every operator with a closed report, plus the holder of each open shift (an
|
||||
// open shift is the last shift_open on its till — but any shift_open's operator
|
||||
// has or had a shift, which is all the dropdown needs).
|
||||
const names = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||
if (op) names.add(op);
|
||||
}
|
||||
const open = this.currentOpenShift();
|
||||
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
||||
if (openOp) names.add(openOp);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||||
listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
@@ -232,12 +255,15 @@ export class ShiftService {
|
||||
};
|
||||
const operator = pl.operator ?? r.identity ?? "?";
|
||||
const startedAt = pl.startedAt ?? r.occurredAt;
|
||||
const till = tillOf(pl);
|
||||
if (opts.till && till !== opts.till) continue;
|
||||
if (opts.operator && operator !== opts.operator) continue;
|
||||
if (opts.from && startedAt < opts.from) continue;
|
||||
if (opts.to && startedAt > opts.to) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
index: r.index,
|
||||
till,
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt: pl.endedAt ?? r.occurredAt,
|
||||
@@ -267,10 +293,10 @@ export class ShiftService {
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/** Require an open shift for the booth money path; returns it or throws. */
|
||||
requireOpenShift() {
|
||||
const open = this.currentOpenShift();
|
||||
if (!open) throw new NoShiftOpenError();
|
||||
/** Require an open shift on a till for its money path; returns it or throws. */
|
||||
requireOpenShift(till: TillId = BOOTH_TILL) {
|
||||
const open = this.currentOpenShift(till);
|
||||
if (!open) throw new NoShiftOpenError(till);
|
||||
return open;
|
||||
}
|
||||
|
||||
@@ -283,9 +309,10 @@ export class ShiftService {
|
||||
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
||||
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
||||
* removal) — historical chain events that still fold in unchanged.
|
||||
* This is what carries across shifts.
|
||||
* This is what carries across shifts. ONE till: every money event is filtered by
|
||||
* `tillOf(payload)` (absent = booth).
|
||||
*/
|
||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||||
#drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
|
||||
const rows = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
@@ -295,16 +322,20 @@ export class ShiftService {
|
||||
(r) =>
|
||||
r.occurredAt <= at &&
|
||||
(r.type === "payment" ||
|
||||
// Car Wash module: money taken at the bay (cash adds to the drawer, card
|
||||
// never does — same tender rule as a parking payment).
|
||||
r.type === "carwash_payment" ||
|
||||
r.type === "cash_in" ||
|
||||
r.type === "cash_out" ||
|
||||
r.type === "cash_movement"),
|
||||
r.type === "cash_movement") &&
|
||||
tillOf(r.payload as LedgerPayload | null) === till,
|
||||
);
|
||||
let balanceMinor = 0;
|
||||
let currency: string | null = null;
|
||||
for (const r of rows) {
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||
if (r.type === "payment") {
|
||||
if (r.type === "payment" || r.type === "carwash_payment") {
|
||||
// Only CASH enters the till; card settles to the bank.
|
||||
if (pl.tender !== "card") balanceMinor += amt;
|
||||
} else if (r.type === "cash_in") {
|
||||
@@ -347,8 +378,11 @@ export class ShiftService {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
/** Which drawer the cash moved in/out of (default: the booth). */
|
||||
till?: TillId;
|
||||
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||
const { type, operator, reason } = args;
|
||||
const till = args.till ?? BOOTH_TILL;
|
||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||
}
|
||||
@@ -365,15 +399,16 @@ export class ShiftService {
|
||||
...(args.currency ? { currency: args.currency } : {}),
|
||||
operator,
|
||||
voucherNo,
|
||||
till,
|
||||
},
|
||||
occurredAt: now,
|
||||
});
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
|
||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
|
||||
this.#logger.info(
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
`${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||
);
|
||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
||||
return { type, till, amountMinor, voucherNo, balanceMinor, printed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,7 +466,7 @@ export class ShiftService {
|
||||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||
*/
|
||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
|
||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||
// Latest review decision per movement id.
|
||||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||
@@ -452,12 +487,15 @@ export class ShiftService {
|
||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||
if (filter?.operator && operator !== filter.operator) continue;
|
||||
const till = tillOf(pl);
|
||||
if (filter?.till && till !== filter.till) continue;
|
||||
const review = reviewByRef.get(r.id);
|
||||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||
if (filter?.status && status !== filter.status) continue;
|
||||
out.push({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
till,
|
||||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||
currency: pl.currency ?? null,
|
||||
reason: pl.reason ?? null,
|
||||
@@ -474,27 +512,28 @@ export class ShiftService {
|
||||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||
}
|
||||
|
||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
||||
* inherited from the chain = the drawer balance at the start instant. */
|
||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
||||
// operator's own (double-open) or another operator's (handover not done). Only
|
||||
// one accountability period at a time.
|
||||
const current = this.currentOpenShift();
|
||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
||||
/** Open a shift for the operator on a till (explicit start). The opening float is
|
||||
* auto-inherited from the chain = that till's drawer balance at the start instant. */
|
||||
async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||
// Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
|
||||
// this operator's own (double-open) or another operator's (handover not done).
|
||||
// One accountability period per drawer at a time. (Another till's shift is
|
||||
// independent: the booth and the wash desk run side by side.)
|
||||
const current = this.currentOpenShift(till);
|
||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
|
||||
const startedAt = new Date().toISOString();
|
||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
|
||||
await this.#log.append({
|
||||
type: "shift_open",
|
||||
source: "manual",
|
||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||
// Record the inherited opening float on the shift_open so it's reproducible
|
||||
// and the next operator's handover figure is fixed in the chain.
|
||||
payload: { operator, openingFloatMinor },
|
||||
payload: { operator, openingFloatMinor, till },
|
||||
occurredAt: startedAt,
|
||||
});
|
||||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||
return { startedAt, openingFloatMinor };
|
||||
this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||
return { startedAt, till, openingFloatMinor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,15 +549,22 @@ export class ShiftService {
|
||||
): Omit<ShiftReport, "printed"> {
|
||||
const operator = open.identity ?? "?";
|
||||
const startedAt = open.occurredAt;
|
||||
const till = tillOf(open.payload as LedgerPayload | null);
|
||||
|
||||
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
|
||||
// the operator who handled the money (decision: sum by payment time).
|
||||
// All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
|
||||
// time = the operator who handled the money (decision: sum by payment time).
|
||||
const payments = this.#db
|
||||
.select()
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "payment"))
|
||||
// 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).
|
||||
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
|
||||
.all()
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||||
.filter(
|
||||
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
|
||||
);
|
||||
|
||||
let cashTotalMinor = 0;
|
||||
let cardTotalMinor = 0;
|
||||
@@ -557,7 +603,7 @@ export class ShiftService {
|
||||
const openingFloatMinor =
|
||||
typeof openPl.openingFloatMinor === "number"
|
||||
? openPl.openingFloatMinor
|
||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||
: this.#drawerBalanceAt(startedAt, till).balanceMinor;
|
||||
|
||||
// Drawer movements within the window, split into added (+) and removed (−).
|
||||
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||
@@ -570,7 +616,8 @@ export class ShiftService {
|
||||
(r) =>
|
||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||
r.occurredAt >= startedAt &&
|
||||
r.occurredAt <= asOf,
|
||||
r.occurredAt <= asOf &&
|
||||
tillOf(r.payload as LedgerPayload | null) === till,
|
||||
);
|
||||
let cashAddedMinor = 0;
|
||||
let cashRemovedMinor = 0;
|
||||
@@ -589,6 +636,7 @@ export class ShiftService {
|
||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||
|
||||
return {
|
||||
till,
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt: asOf,
|
||||
@@ -615,17 +663,18 @@ export class ShiftService {
|
||||
* projection the Z-report prints, so the operator sees exactly what their close
|
||||
* will show. See wiki/concepts/shift.md.
|
||||
*/
|
||||
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||
const open = this.currentOpenShift();
|
||||
currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||
const open = this.currentOpenShift(till);
|
||||
if (!open) return null;
|
||||
const asOf = new Date().toISOString();
|
||||
return { ...this.#summariseWindow(open, asOf), asOf };
|
||||
}
|
||||
|
||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
||||
async close(operator: string): Promise<ShiftReport> {
|
||||
const open = this.openShiftFor(operator);
|
||||
if (!open) throw new NoOpenShiftError(operator);
|
||||
/** Close the operator's open shift on a till: sum its payments in the window, sign +
|
||||
* print the Z-report. */
|
||||
async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
|
||||
const open = this.openShiftFor(operator, till);
|
||||
if (!open) throw new NoOpenShiftError(operator, till);
|
||||
const endedAt = new Date().toISOString();
|
||||
|
||||
const report = this.#summariseWindow(open, endedAt);
|
||||
@@ -652,6 +701,7 @@ export class ShiftService {
|
||||
identity: operator,
|
||||
payload: {
|
||||
operator,
|
||||
till,
|
||||
startedAt,
|
||||
endedAt,
|
||||
cashTotalMinor,
|
||||
@@ -673,7 +723,7 @@ export class ShiftService {
|
||||
const printed = await this.#printZReport(report);
|
||||
|
||||
this.#logger.info(
|
||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||
`${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||
);
|
||||
return { ...report, printed };
|
||||
@@ -692,6 +742,9 @@ export class ShiftService {
|
||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||||
const lines = [
|
||||
// Which drawer this report reconciles — only printed off the booth, so booth
|
||||
// slips stay byte-identical to before tills existed.
|
||||
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
|
||||
`Operatori: ${r.operator}`,
|
||||
`Nga: ${zStamp(r.startedAt)}`,
|
||||
`Deri: ${zStamp(r.endedAt)}`,
|
||||
@@ -737,6 +790,7 @@ export class ShiftService {
|
||||
operator: string;
|
||||
currency: string | null;
|
||||
at: string;
|
||||
till: TillId;
|
||||
}): Promise<boolean> {
|
||||
const printer = await this.#boothPrinter();
|
||||
if (!printer) {
|
||||
@@ -748,6 +802,7 @@ export class ShiftService {
|
||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||
const lines = [
|
||||
`Mandat Nr.: ${v.voucherNo}`,
|
||||
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
|
||||
`Data: ${zStamp(v.at)}`,
|
||||
"",
|
||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||
|
||||
Reference in New Issue
Block a user