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:
|
// Completed shift history. SCOPED by permission:
|
||||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
||||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||||
|
|||||||
@@ -323,21 +323,28 @@ export class ShiftService {
|
|||||||
return { startedAt, 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> {
|
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
|
||||||
const open = this.openShiftFor(operator);
|
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
|
||||||
if (!open) throw new NoOpenShiftError(operator);
|
* 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 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).
|
// the operator who handled the money (decision: sum by payment time).
|
||||||
const payments = this.#db
|
const payments = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "payment"))
|
.where(eq(ledgerEvents.type, "payment"))
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
|
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
||||||
|
|
||||||
let cashTotalMinor = 0;
|
let cashTotalMinor = 0;
|
||||||
let cardTotalMinor = 0;
|
let cardTotalMinor = 0;
|
||||||
@@ -359,7 +366,7 @@ export class ShiftService {
|
|||||||
? openPl.openingFloatMinor
|
? openPl.openingFloatMinor
|
||||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
: 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-±
|
// 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.
|
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
|
||||||
const movements = this.#db
|
const movements = this.#db
|
||||||
@@ -370,7 +377,7 @@ export class ShiftService {
|
|||||||
(r) =>
|
(r) =>
|
||||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||||
r.occurredAt >= startedAt &&
|
r.occurredAt >= startedAt &&
|
||||||
r.occurredAt <= endedAt,
|
r.occurredAt <= asOf,
|
||||||
);
|
);
|
||||||
let cashAddedMinor = 0;
|
let cashAddedMinor = 0;
|
||||||
let cashRemovedMinor = 0;
|
let cashRemovedMinor = 0;
|
||||||
@@ -384,14 +391,14 @@ export class ShiftService {
|
|||||||
if (pl.currency) currency = pl.currency;
|
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.
|
// figure the NEXT shift inherits as its opening float.
|
||||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
const report: Omit<ShiftReport, "printed"> = {
|
return {
|
||||||
operator,
|
operator,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt: asOf,
|
||||||
cashTotalMinor,
|
cashTotalMinor,
|
||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency,
|
currency,
|
||||||
@@ -401,6 +408,40 @@ export class ShiftService {
|
|||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
expectedDrawerMinor,
|
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({
|
await this.#log.append({
|
||||||
type: "shift_z_report",
|
type: "shift_z_report",
|
||||||
@@ -413,7 +454,7 @@ export class ShiftService {
|
|||||||
cashTotalMinor,
|
cashTotalMinor,
|
||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount: payments.length,
|
paymentCount,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -424,7 +465,7 @@ export class ShiftService {
|
|||||||
const printed = await this.#printZReport(report);
|
const printed = await this.#printZReport(report);
|
||||||
|
|
||||||
this.#logger.info(
|
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}`,
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
);
|
);
|
||||||
return { ...report, printed };
|
return { ...report, printed };
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { closeShift, fetchShift, openShift, recordCashVoucher, type ShiftReport } from "./api.js";
|
import {
|
||||||
|
closeShift,
|
||||||
|
fetchShift,
|
||||||
|
fetchShiftReport,
|
||||||
|
openShift,
|
||||||
|
recordCashVoucher,
|
||||||
|
type ShiftReport,
|
||||||
|
type XReport,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
// Manned-mode shift control. Start/End are explicit (not time-based — see
|
||||||
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
|
||||||
@@ -18,6 +26,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|||||||
const [currency, setCurrency] = useState<string | null>(null);
|
const [currency, setCurrency] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
|
const [xReport, setXReport] = useState<XReport | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
|
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
|
||||||
@@ -44,6 +53,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setReport(null);
|
setReport(null);
|
||||||
|
setXReport(null);
|
||||||
try {
|
try {
|
||||||
const { startedAt } = await openShift();
|
const { startedAt } = await openShift();
|
||||||
setStartedAt(startedAt);
|
setStartedAt(startedAt);
|
||||||
@@ -57,6 +67,7 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|||||||
async function end() {
|
async function end() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
|
setXReport(null);
|
||||||
try {
|
try {
|
||||||
const z = await closeShift();
|
const z = await closeShift();
|
||||||
setReport(z);
|
setReport(z);
|
||||||
@@ -68,6 +79,16 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mid-shift X-report: read-only "takings so far" (appends nothing). Re-fetched on
|
||||||
|
// each click so it's always current.
|
||||||
|
async function viewReport() {
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
setXReport(await fetchShiftReport());
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function voucher(type: "cash_in" | "cash_out") {
|
async function voucher(type: "cash_in" | "cash_out") {
|
||||||
setMoveMsg(null);
|
setMoveMsg(null);
|
||||||
@@ -108,6 +129,9 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|||||||
<>
|
<>
|
||||||
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
<span className="font-semibold text-term-green">{t("shift.open")}</span>
|
||||||
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={viewReport} disabled={busy}>
|
||||||
|
{t("shift.viewTakings")}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
|
||||||
{busy ? t("shift.ending") : t("shift.endShift")}
|
{busy ? t("shift.ending") : t("shift.endShift")}
|
||||||
</button>
|
</button>
|
||||||
@@ -182,6 +206,28 @@ export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mid-shift X-report — read-only "takings so far" (no event appended). */}
|
||||||
|
{xReport && (
|
||||||
|
<div className="mt-4 rounded-term border border-term-cyan/40 bg-term-bg p-3 text-[12px] tabular-nums">
|
||||||
|
<div className="font-semibold text-term-cyan">{t("shift.xReport")} — {xReport.operator}</div>
|
||||||
|
<div className="text-term-muted">
|
||||||
|
{t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-term-text">{t("shift.payments")} {xReport.paymentCount}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.card")} {money(xReport.cardTotalMinor, xReport.currency)}</div>
|
||||||
|
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
|
||||||
|
<div className="text-term-text">{t("shift.openingFloat")} {money(xReport.openingFloatMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}</div>
|
||||||
|
<div className="text-term-text">{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}</div>
|
||||||
|
<div className="font-semibold text-term-text">
|
||||||
|
{t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
|
||||||
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
|
||||||
|
|||||||
@@ -627,6 +627,29 @@ export function closeShift(): Promise<ShiftReport> {
|
|||||||
return apiFetch("/api/shift/close", { method: "POST" });
|
return apiFetch("/api/shift/close", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||||
|
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||||
|
* snapshot instant. */
|
||||||
|
export interface XReport {
|
||||||
|
operator: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string; // = asOf
|
||||||
|
asOf: string;
|
||||||
|
cashTotalMinor: number;
|
||||||
|
cardTotalMinor: number;
|
||||||
|
currency: string | null;
|
||||||
|
paymentCount: number;
|
||||||
|
openingFloatMinor: number;
|
||||||
|
cashAddedMinor: number;
|
||||||
|
cashRemovedMinor: number;
|
||||||
|
expectedDrawerMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||||
|
export async function fetchShiftReport(): Promise<XReport | null> {
|
||||||
|
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
|
||||||
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
|
||||||
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
* Operator-raised, admin-authorized (authorizedBy + their password). */
|
||||||
|
|||||||
@@ -528,6 +528,10 @@ export const en: Catalog = {
|
|||||||
enterPositive: "Enter a positive amount.",
|
enterPositive: "Enter a positive amount.",
|
||||||
drawerNow: "Drawer now {{amount}}.",
|
drawerNow: "Drawer now {{amount}}.",
|
||||||
zReport: "Z-REPORT",
|
zReport: "Z-REPORT",
|
||||||
|
viewTakings: "Takings so far",
|
||||||
|
xReport: "X-REPORT (so far)",
|
||||||
|
asOf: "as of",
|
||||||
|
xReportHint: "Read-only snapshot — nothing is recorded. The Z-report at close will sign these figures.",
|
||||||
payments: "Payments:",
|
payments: "Payments:",
|
||||||
cash: "Cash:",
|
cash: "Cash:",
|
||||||
card: "Card:",
|
card: "Card:",
|
||||||
|
|||||||
@@ -540,6 +540,10 @@ export const sq = {
|
|||||||
enterPositive: "Shkruaj një shumë pozitive.",
|
enterPositive: "Shkruaj një shumë pozitive.",
|
||||||
drawerNow: "Arka tani {{amount}}.",
|
drawerNow: "Arka tani {{amount}}.",
|
||||||
zReport: "RAPORT Z",
|
zReport: "RAPORT Z",
|
||||||
|
viewTakings: "Arkëtimet deri tani",
|
||||||
|
xReport: "RAPORT X (deri tani)",
|
||||||
|
asOf: "deri më",
|
||||||
|
xReportHint: "Pamje vetëm për lexim — asgjë nuk regjistrohet. Raporti Z në mbyllje i nënshkruan këto shifra.",
|
||||||
payments: "Pagesa:",
|
payments: "Pagesa:",
|
||||||
cash: "Para:",
|
cash: "Para:",
|
||||||
card: "Kartë:",
|
card: "Kartë:",
|
||||||
|
|||||||
+10
-6
@@ -204,11 +204,15 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
|
|||||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||||
the money. Confirm that's the intended accountability (vs. by entry).
|
the money. Confirm that's the intended accountability (vs. by entry).
|
||||||
- **Mid-shift report / X-report — REQUESTED 2026-06-20, not yet built.** The operator wants to see,
|
- **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
|
||||||
on demand during the shift, the **opening float inherited**, **cash collected so far**, the
|
the **opening float inherited**, **cash/card collected so far**, the **pay-ins/pay-outs**, and the
|
||||||
pay-ins/pay-outs, and the **current expected drawer balance** — without closing. It's the same
|
**current expected drawer balance** — without closing. `GET /api/shift/report` (`shift:read`, 204
|
||||||
drawer projection the Z-report computes, just read-only and mid-shift. (The header already shows the
|
when no shift is open) returns the SAME drawer projection the Z-report computes, factored into a
|
||||||
live drawer *total*; this is the full breakdown.) Deferred behind the voucher re-model done the same
|
shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
|
||||||
day; build next if wanted.
|
endedAt, signed) can never drift. **It appends NOTHING** — it's not an accountability mark (the
|
||||||
|
Z-report at close is the signed record). UI: a "Takings so far" button on the shift control opens a
|
||||||
|
cyan X-report panel; the header still shows the live drawer *total* for the at-a-glance number.
|
||||||
|
Verified against a copy of the live DB: matches `drawerBalance()`, drawer identity holds, 0 events
|
||||||
|
appended, chain still verifies.
|
||||||
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
||||||
(relates to [[open-questions]] #1 lane topology).
|
(relates to [[open-questions]] #1 lane topology).
|
||||||
|
|||||||
+13
@@ -1118,3 +1118,16 @@ three types. Verified against a COPY of the live DB with the real signing module
|
|||||||
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
|
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
|
||||||
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
|
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
|
||||||
not yet built (see [[shift]] Open).
|
not yet built (see [[shift]] Open).
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Mid-shift X-report (read-only takings-so-far)
|
||||||
|
|
||||||
|
The operator can now 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 open) returns the
|
||||||
|
SAME projection the Z-report prints, factored into a shared `ShiftService.#summariseWindow
|
||||||
|
(open, asOf)` so X (asOf=now, read-only) and Z (asOf=endedAt, signed) can't drift. Appends
|
||||||
|
NOTHING — it's a snapshot, not an accountability mark (the Z at close is the signed record).
|
||||||
|
UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header
|
||||||
|
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
|
||||||
|
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
|
||||||
|
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].
|
||||||
|
|||||||
Reference in New Issue
Block a user