00f3d141b6
Dates were raw ISO on printed slips and time-only in the UI (a session from two days ago showed just "10:48"). Make them human and day-relative. Also fix a latent toggle bug surfaced while testing. Dates: - Printed tickets/receipts/subscription cards now show "19 Qershor 2026 10:48:25" (Albanian month, 24h with seconds) instead of YYYY-MM-DD HH:MM. stamp() exported as formatStampSq so the shift Z-report shares it. - Shift Z-report is now Albanian (Operatori/Nga/Deri/Para në dorë/Arka…), was English-only with ISO dates. - Web sessions/logs/history show relative days: "Sot 10:48" / "Dje 17:33" / "17 Qershor 10:48" via formatRelativeDateTime(). Month names come from the i18n catalog (common.months), NOT Intl — the appliance browser's ICU lacks Albanian locale data and Intl silently falls back to English month names. Toggle fix: - The language + theme toggles read the active value from the TanStack Router context `user`, which is captured at route-resolution time and does not re-render on setUser. After one switch the highlight froze and the equality guard blocked switching back until a page refresh. Drive them off live state instead: language from i18n.language (useTranslation subscribes to languageChanged), theme from local useState. (Bug dated to 040c0ff.) Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||
import type { LedgerPayload } from "@parking/shared";
|
||
import type { FastifyBaseLogger } from "fastify";
|
||
import type { EventLog } from "./event-log.js";
|
||
|
||
// Shift service (manned mode only). A shift is an operator's accountability period,
|
||
// 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.
|
||
// 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) {
|
||
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`,
|
||
);
|
||
this.name = "ShiftAlreadyOpenError";
|
||
this.heldBy = heldBy;
|
||
}
|
||
}
|
||
export class NoOpenShiftError extends Error {
|
||
constructor(operator: string) {
|
||
super(`operator ${operator} has no open 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. */
|
||
export class NoShiftOpenError extends Error {
|
||
constructor() {
|
||
super("no shift is open — open a shift before processing tickets");
|
||
this.name = "NoShiftOpenError";
|
||
}
|
||
}
|
||
|
||
/** A COMPLETED shift, reconstructed from its signed `shift_z_report` (which carries
|
||
* all the figures in its payload). This is the unit of the shift-history feature.
|
||
* `id` is the z_report's ledger id (stable, for the UI list key / future deep-link). */
|
||
export interface ShiftSummary {
|
||
readonly id: string;
|
||
readonly index: number;
|
||
readonly operator: string;
|
||
readonly startedAt: string;
|
||
readonly endedAt: string;
|
||
readonly cashTotalMinor: number;
|
||
readonly cardTotalMinor: number;
|
||
readonly currency: string | null;
|
||
readonly paymentCount: number;
|
||
readonly openingFloatMinor: number;
|
||
readonly cashAddedMinor: number;
|
||
readonly cashRemovedMinor: number;
|
||
readonly expectedDrawerMinor: number;
|
||
}
|
||
|
||
export interface ShiftReport {
|
||
readonly operator: string;
|
||
readonly startedAt: string;
|
||
readonly endedAt: string;
|
||
readonly cashTotalMinor: number;
|
||
readonly cardTotalMinor: number;
|
||
readonly currency: string | null;
|
||
readonly paymentCount: number;
|
||
// --- Drawer (physical cash till; carries across shifts) ---
|
||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||
readonly openingFloatMinor: number;
|
||
/** Admin cash LOADED into the drawer during the shift (sum of + movements). */
|
||
readonly cashAddedMinor: number;
|
||
/** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */
|
||
readonly cashRemovedMinor: number;
|
||
/** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */
|
||
readonly expectedDrawerMinor: number;
|
||
readonly printed: boolean;
|
||
}
|
||
|
||
export class InvalidCashMovementError extends Error {
|
||
constructor(msg: string) {
|
||
super(msg);
|
||
this.name = "InvalidCashMovementError";
|
||
}
|
||
}
|
||
|
||
export class ShiftService {
|
||
readonly #db: Db;
|
||
readonly #log: EventLog;
|
||
readonly #logger: FastifyBaseLogger;
|
||
|
||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||
this.#db = db;
|
||
this.#log = log;
|
||
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());
|
||
}
|
||
|
||
/** 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
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.identity, operator))
|
||
.orderBy(ledgerEvents.index)
|
||
.all()
|
||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||
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.
|
||
*/
|
||
currentOpenShift() {
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.orderBy(ledgerEvents.index)
|
||
.all()
|
||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
||
const last = rows[rows.length - 1];
|
||
return last && last.type === "shift_open" ? last : null;
|
||
}
|
||
|
||
/**
|
||
* COMPLETED shift history, newest first. Each closed shift is one signed
|
||
* `shift_z_report` whose payload already holds every figure, so this is a simple
|
||
* read of those rows (no re-summing). Optional filters:
|
||
* - operator: only this operator's shifts (the `identity` on the z_report).
|
||
* - from/to: ISO timestamps; keep shifts whose START falls in [from, to].
|
||
* The open shift (no z_report yet) is intentionally excluded — it's not a
|
||
* completed accountability period. Use `currentOpenShift()` for the live one.
|
||
*/
|
||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
||
.orderBy(ledgerEvents.index)
|
||
.all();
|
||
|
||
const out: ShiftSummary[] = [];
|
||
for (const r of rows) {
|
||
const pl = (r.payload ?? {}) as LedgerPayload & {
|
||
operator?: string;
|
||
startedAt?: string;
|
||
endedAt?: string;
|
||
cashTotalMinor?: number;
|
||
cardTotalMinor?: number;
|
||
paymentCount?: number;
|
||
openingFloatMinor?: number;
|
||
cashAddedMinor?: number;
|
||
cashRemovedMinor?: number;
|
||
expectedDrawerMinor?: number;
|
||
};
|
||
const operator = pl.operator ?? r.identity ?? "?";
|
||
const startedAt = pl.startedAt ?? r.occurredAt;
|
||
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,
|
||
operator,
|
||
startedAt,
|
||
endedAt: pl.endedAt ?? r.occurredAt,
|
||
cashTotalMinor: pl.cashTotalMinor ?? 0,
|
||
cardTotalMinor: pl.cardTotalMinor ?? 0,
|
||
currency: pl.currency ?? null,
|
||
paymentCount: pl.paymentCount ?? 0,
|
||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||
expectedDrawerMinor: pl.expectedDrawerMinor ?? 0,
|
||
});
|
||
}
|
||
// Newest first for the history list.
|
||
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();
|
||
return open;
|
||
}
|
||
|
||
/**
|
||
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
|
||
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
|
||
* payments add to the drawer; card payments never touch it; cash_movement amounts
|
||
* (signed: + load, − removal) adjust it. This is what carries across shifts.
|
||
*/
|
||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
||
const rows = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.orderBy(ledgerEvents.index)
|
||
.all()
|
||
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
|
||
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") {
|
||
// Only CASH enters the till; card settles to the bank.
|
||
if (pl.tender !== "card") balanceMinor += amt;
|
||
} else {
|
||
// cash_movement amount is signed (+ load, − removal).
|
||
balanceMinor += amt;
|
||
}
|
||
if (pl.currency) currency = pl.currency;
|
||
}
|
||
return { balanceMinor, currency };
|
||
}
|
||
|
||
/**
|
||
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
|
||
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
|
||
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
|
||
*/
|
||
async recordCashMovement(
|
||
operator: string,
|
||
amountMinor: number,
|
||
reason: string,
|
||
currency?: string,
|
||
): Promise<{ amountMinor: number; balanceMinor: number }> {
|
||
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
|
||
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
|
||
}
|
||
const now = new Date().toISOString();
|
||
await this.#log.append({
|
||
type: "cash_movement",
|
||
source: "manual",
|
||
identity: operator, // who moved the cash (admin)
|
||
payload: {
|
||
amountMinor,
|
||
...(reason ? { reason } : {}),
|
||
...(currency ? { currency } : {}),
|
||
operator,
|
||
},
|
||
occurredAt: now,
|
||
});
|
||
const { balanceMinor } = this.#drawerBalanceAt(now);
|
||
this.#logger.info(
|
||
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||
);
|
||
return { amountMinor, balanceMinor };
|
||
}
|
||
|
||
/** 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);
|
||
const startedAt = new Date().toISOString();
|
||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||
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 },
|
||
occurredAt: startedAt,
|
||
});
|
||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||
return { startedAt, openingFloatMinor };
|
||
}
|
||
|
||
/** 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);
|
||
const startedAt = open.occurredAt;
|
||
const endedAt = new Date().toISOString();
|
||
|
||
// All payments taken in [startedAt, endedAt], 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"))
|
||
.all()
|
||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||
|
||
let cashTotalMinor = 0;
|
||
let cardTotalMinor = 0;
|
||
let currency: string | null = null;
|
||
for (const p of payments) {
|
||
const pl = (p.payload ?? {}) as LedgerPayload;
|
||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||
if (pl.tender === "card") cardTotalMinor += amt;
|
||
else cashTotalMinor += amt;
|
||
if (pl.currency) currency = pl.currency;
|
||
}
|
||
|
||
// --- Drawer figures ---
|
||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||
// fall back to a fresh fold if an older shift_open lacks it.
|
||
const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number };
|
||
const openingFloatMinor =
|
||
typeof openPl.openingFloatMinor === "number"
|
||
? openPl.openingFloatMinor
|
||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||
|
||
// Cash movements within the shift window, split into added (+) and removed (−).
|
||
const movements = this.#db
|
||
.select()
|
||
.from(ledgerEvents)
|
||
.where(eq(ledgerEvents.type, "cash_movement"))
|
||
.all()
|
||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||
let cashAddedMinor = 0;
|
||
let cashRemovedMinor = 0;
|
||
for (const m of movements) {
|
||
const pl = (m.payload ?? {}) as LedgerPayload;
|
||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||
if (amt >= 0) cashAddedMinor += amt;
|
||
else cashRemovedMinor += -amt; // store as a positive magnitude
|
||
if (pl.currency) currency = pl.currency;
|
||
}
|
||
|
||
// Expected drawer at close = opening + cash taken + added − removed. This is the
|
||
// figure the NEXT shift inherits as its opening float.
|
||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||
|
||
const report: Omit<ShiftReport, "printed"> = {
|
||
operator,
|
||
startedAt,
|
||
endedAt,
|
||
cashTotalMinor,
|
||
cardTotalMinor,
|
||
currency,
|
||
paymentCount: payments.length,
|
||
openingFloatMinor,
|
||
cashAddedMinor,
|
||
cashRemovedMinor,
|
||
expectedDrawerMinor,
|
||
};
|
||
|
||
await this.#log.append({
|
||
type: "shift_z_report",
|
||
source: "manual",
|
||
identity: operator,
|
||
payload: {
|
||
operator,
|
||
startedAt,
|
||
endedAt,
|
||
cashTotalMinor,
|
||
cardTotalMinor,
|
||
currency: currency ?? undefined,
|
||
paymentCount: payments.length,
|
||
openingFloatMinor,
|
||
cashAddedMinor,
|
||
cashRemovedMinor,
|
||
expectedDrawerMinor,
|
||
},
|
||
});
|
||
|
||
const printed = await this.#printZReport(report);
|
||
|
||
this.#logger.info(
|
||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
|
||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||
);
|
||
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). */
|
||
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
|
||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||
const lines = [
|
||
`Operatori: ${r.operator}`,
|
||
`Nga: ${zStamp(r.startedAt)}`,
|
||
`Deri: ${zStamp(r.endedAt)}`,
|
||
"",
|
||
`Pagesa: ${r.paymentCount}`,
|
||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||
"",
|
||
"-- Arka --",
|
||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||
`Arka e pritur: ${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;
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
}
|
||
}
|