Files
parking_solution/apps/server/src/shift-service.ts
T
julian 00f3d141b6 feat: human + relative dates; fix language/theme toggle stale-context
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
2026-06-19 11:14:06 +02:00

438 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}