A shift is two signed ledger events, no mutable table: new shift_open event type + existing shift_z_report. The operator is the logged-in user (carried in event identity); a shift is open iff their latest shift event is a shift_open. ShiftService: close sums payment events in [start,end] by tender (cash/card, by payment time), appends the signed shift_z_report (totals/counts/window), and prints via a new generic PrinterDevice.printReport(title, lines) (Rongta ESC/POS text) to a booth-receipt printer. Print is best-effort — a failed print does not undo the signed close. Routes (cashier/operator/admin): GET /api/shift/current, POST /api/shift/open (409 if open), POST /api/shift/close (409 if none). Web ShiftControl in the shell (non-readonly): Start/End + Z-report totals. Verified: open -> double-open 409 -> payments (cash+card; one outside the window excluded) -> close totals correct + signed + printed -> close-again 409 -> re-open ok; readonly 403; verifyChain ok.
5.4 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-15 | open |
Shift (manned mode) & the Z-Report
A shift is one operator's accountability period at a manned booth: from the moment they take over to the moment they hand over, however long that is. At the end, the system signs and prints a Z-report — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
Shifts exist ONLY in manned mode
A shift is fundamentally a human accountability boundary — "this person was responsible for the takings from here to here." In the autonomous-direction system there is no operator and no shift; what replaces it is the pay station's cash-collection cycle (who emptied the vault, when, how much vs. what the signed log expected) plus ongoing reconciliation — a separate concept, not a shift. So shifts are scoped to manned operation; don't force one model across both.
A shift is NOT time-based
It is delimited by explicit operator action, never by a clock:
- Booth reality: relief comes late, doesn't show, or one operator is forced to work two shifts in a row. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active operator. So the local-jwt-auth (valid until logout).
- Start Shift / End Shift are explicit, and independent of login. One login can span many shifts; a back-to-back double is simply End Shift → Start Shift again, no re-login. The operator (the same person or the next) marks the boundary.
login ——————————————————————————————————————————————→ (until logout)
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
What End Shift does
- Determine the shift's payment set: the signed
paymentevents (parking-session, append-only-event-chain) between this shift's start mark and now. - Sum by tender:
cashTotal, andcardTotalfrom the POS/terminal if a POS is configured (the card line is omitted when there's no terminal). - Append a signed
shift_z_reportevent (type already inpackages/shared):{ operator, startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }— chained to the prior Z so a missing/out-of-order Z-report is itself visible. - Print the Z-report (cash total, POS total if any, counts, shift window, operator) on the booth printer.
That's the whole human-side requirement: print the cash and the POS (if any). No blind count, no variance gate, no manager override.
As-built (2026-06-16)
- A shift is two signed ledger events, no mutable table (decision):
shift_open(new event type) at start,shift_z_reportat close. The operator is the logged-in user, carried in the eventidentity; a shift is open iff that operator's most recent shift event is ashift_open.ShiftService(apps/server/src/shift-service.ts). - Close sums
paymentevents in[startedAt, endedAt]by tender (cash vs. card, by payment time), appends the signedshift_z_report(totals + counts + window), then prints via the new genericPrinterDevice.printReport(title, lines)(Rongta ESC/POS text) to a booth-receipt printer. Printing is best-effort — a failed print does not undo the signed close (the event is the record;printed:falseis returned). - Routes (
routes/shift.ts, cashier/operator/admin):GET /api/shift/current,POST /api/shift/open(409 if already open),POST /api/shift/close(409 if none open). UIShiftControlin the app shell (non-readonly): Start/End + the Z-report totals. - Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) → close totals correct + signed + printed → close-again 409 → re-open works; readonly 403; verifyChain ok.
Where the fraud control actually lives
Deliberately not in a shift-close ceremony. Because every payment is a signed event in the append-only chain, the printed cash figure is the system's tamper-evident truth. A manager reconciles the signed Z-report against the actual drawer and the bank/POS batch later — that's reconciliation, the real control (deferred). The tradeoff vs. a heavier control is purely when a skim is caught (after the fact, by a human), not whether.
Optional enhancement (not building now): blind cash count. Have the operator enter the counted cash before the system reveals the expected figure, and record the variance into the
shift_z_report. Blindness removes the operator's ability to back-fill their declaration to match expectation, catching a skim at close rather than later. Explicitly out of scope per 2026-06-15; documented as a clean add-on if ever wanted.
Open
- 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 the money. Confirm that's the intended accountability (vs. by entry).
- Mid-shift report / X-report (read-only "so far" total without closing) — add if booths want it; the sum is the same projection.
- Multiple lanes/booths — whether a shift is per-operator, per-booth, or per-site (relates to open-questions #1 lane topology).