feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float inherited, cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance — without closing. GET /api/shift/report (shift:read; 204 when no shift is open) returns the same drawer projection the Z-report computes. Factored that math into a shared ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now, read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't drift. The X-report appends NOTHING — it's a snapshot, not an accountability mark; the Z-report at close remains the signed record. UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header still shows the live drawer total for the at-a-glance figure. Verified against a copy of the live DB: X figures match drawerBalance(), the drawer identity holds, zero events appended, chain still verifies. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -60,6 +60,16 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db:
|
||||
};
|
||||
});
|
||||
|
||||
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
||||
const report = shift.currentReport();
|
||||
if (!report) return reply.code(204).send();
|
||||
return report;
|
||||
});
|
||||
|
||||
// Completed shift history. SCOPED by permission:
|
||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||
|
||||
@@ -323,21 +323,28 @@ export class ShiftService {
|
||||
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);
|
||||
/**
|
||||
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
|
||||
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
|
||||
* mid-shift X-report (asOf = now, shift still open) and the Z-report at close
|
||||
* (asOf = endedAt). The figures are identical projections; only the persistence
|
||||
* differs (X = read-only, Z = signed + carried forward).
|
||||
*/
|
||||
#summariseWindow(
|
||||
open: typeof ledgerEvents.$inferSelect,
|
||||
asOf: string,
|
||||
): Omit<ShiftReport, "printed"> {
|
||||
const operator = open.identity ?? "?";
|
||||
const startedAt = open.occurredAt;
|
||||
const endedAt = new Date().toISOString();
|
||||
|
||||
// All payments taken in [startedAt, endedAt], summed by tender. Payment time =
|
||||
// All payments taken 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"))
|
||||
.all()
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||||
|
||||
let cashTotalMinor = 0;
|
||||
let cardTotalMinor = 0;
|
||||
@@ -359,7 +366,7 @@ export class ShiftService {
|
||||
? openPl.openingFloatMinor
|
||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
||||
|
||||
// Drawer movements within the shift window, split into added (+) and removed (−).
|
||||
// Drawer movements within the window, split into added (+) and removed (−).
|
||||
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
|
||||
const movements = this.#db
|
||||
@@ -370,7 +377,7 @@ export class ShiftService {
|
||||
(r) =>
|
||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||
r.occurredAt >= startedAt &&
|
||||
r.occurredAt <= endedAt,
|
||||
r.occurredAt <= asOf,
|
||||
);
|
||||
let cashAddedMinor = 0;
|
||||
let cashRemovedMinor = 0;
|
||||
@@ -384,14 +391,14 @@ export class ShiftService {
|
||||
if (pl.currency) currency = pl.currency;
|
||||
}
|
||||
|
||||
// Expected drawer at close = opening + cash taken + added − removed. This is the
|
||||
// Expected drawer = opening + cash taken + added − removed. At close this is the
|
||||
// figure the NEXT shift inherits as its opening float.
|
||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||
|
||||
const report: Omit<ShiftReport, "printed"> = {
|
||||
return {
|
||||
operator,
|
||||
startedAt,
|
||||
endedAt,
|
||||
endedAt: asOf,
|
||||
cashTotalMinor,
|
||||
cardTotalMinor,
|
||||
currency,
|
||||
@@ -401,6 +408,40 @@ export class ShiftService {
|
||||
cashRemovedMinor,
|
||||
expectedDrawerMinor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-shift X-report: a READ-ONLY "so far" snapshot of the open shift's takings +
|
||||
* drawer, computed as of now. Appends nothing (it's not an accountability mark —
|
||||
* the Z-report at close is). Returns null when no shift is open. The same
|
||||
* 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();
|
||||
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);
|
||||
const endedAt = new Date().toISOString();
|
||||
|
||||
const report = this.#summariseWindow(open, endedAt);
|
||||
const {
|
||||
startedAt,
|
||||
cashTotalMinor,
|
||||
cardTotalMinor,
|
||||
currency,
|
||||
paymentCount,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
expectedDrawerMinor,
|
||||
} = report;
|
||||
|
||||
await this.#log.append({
|
||||
type: "shift_z_report",
|
||||
@@ -413,7 +454,7 @@ export class ShiftService {
|
||||
cashTotalMinor,
|
||||
cardTotalMinor,
|
||||
currency: currency ?? undefined,
|
||||
paymentCount: payments.length,
|
||||
paymentCount,
|
||||
openingFloatMinor,
|
||||
cashAddedMinor,
|
||||
cashRemovedMinor,
|
||||
@@ -424,7 +465,7 @@ export class ShiftService {
|
||||
const printed = await this.#printZReport(report);
|
||||
|
||||
this.#logger.info(
|
||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
|
||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||
);
|
||||
return { ...report, printed };
|
||||
|
||||
Reference in New Issue
Block a user