Files
parking_solution/wiki/concepts/shift.md
T
julian 0074e82a2a docs(wiki): activity-log explainability, dates/i18n, KP-300H barcode fix
Record this session's work across the affected pages + three log entries.

- ticket-encoding: id 13→11 digits (guess-resistance rationale, legacy-safe
  validation) + a barcode-geometry rule (symbol dots must fit the narrowest
  deployed printer's line — the KP-300H 72mm overflow).
- rongta-printer: KP-300H raster-garbage root cause (line overflow, not
  corruption), sendRaw graceful-close fix, Albanian human dates (formatStampSq).
- i18n: localized ledger reason codes, relative/human dates + the
  "browser ICU lacks Albanian" gotcha, toggle stale-router-context fix.
- shift: Albanian Z-report, shift-history UI + permission scoping.
- booth-console: explainable activity log (inline reasons/badges, event-detail
  modal with snapshots + audit disclosure, subscriber names, failed-snapshot
  tiles).
- index/log updated; all added wikilinks resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 11:41:27 +02:00

12 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
shifts
anti-fraud
2026-06-19 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.

Site-wide single-open + the booth gate (decided + built 2026-06-18)

A shift is a site-wide accountability period: at most one shift may be open at a time across the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit falls inside exactly one operator's window. Consequences:

  • Login ≠ shift. An operator may log in off-shift (e.g. to review their own past activity); logging in never opens a shift. Conversely a shift can't be opened by two people at once.
  • Opening is refused when ANY shift is open — whether the operator's own (double-open) or another operator's (handover not done). ShiftService.open() checks currentOpenShift() (the single site-wide open shift = most recent shift event on the whole chain is a shift_open), and throws ShiftAlreadyOpenError carrying heldBy so the UI can name who holds it. Operator B can only start once operator A closes — that's the handover.
  • The booth money path is GATED on an open shift. /api/pay, /api/exit, /api/voucher, /api/barrier/reopen run a requireShift preHandler that 409s { code: "no_shift" } when none is open. Read-only lookups (/api/session/:id, /api/sessions/active, /api/pay/quote) stay ungated so the modal can still display a session and prompt "open a shift". The server is the enforcement point; the UI mirrors it (see booth-console).
  • "Operate under someone else's shift" is deliberately disallowed. B's takings would land in A's Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open.
  • Logs are per-shift. The booth live feed shows only events from the open shift's window (GET /api/events?since=<shiftStart>); no shift open → no feed, just the "open a shift" prompt.

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

  1. Determine the shift's payment set: the signed payment events (parking-session, append-only-event-chain) between this shift's start mark and now. This includes a subscription fee an operator collects during the shift (sold/renewed at the booth → a signed payment, deferred build) — it folds into this set like any transient taking.
  2. Sum by tender: cashTotal, and cardTotal from the POS/terminal if a POS is configured (the card line is omitted when there's no terminal).
  3. Append a signed shift_z_report event (type already in packages/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.
  4. 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.

Z-report is now Albanian (2026-06-19). The printed Z-report labels were hardcoded English (Operator:/From:/Cash:) with raw ISO timestamps; now fully Albanian (Operatori/Nga/Deri/ Para në dorë/-- Arka --/Arka e pritur…) with the human date format 19 Qershor 2026 10:48:25, shared via formatStampSq from rongta-printer. Consistent with the "[[i18n|printed paper is always Albanian]]" rule — independent of the operator's UI language.

Shift history UI + permission scoping (2026-06-19)

A read-only shift-history screen (GET /api/shifts) lists completed shifts — each a signed shift_z_report folded for its figures (no re-summing), newest-first, expandable to the drawer reconciliation. Scoped server-side by permission (dynamic local-jwt-auth): an operator with shift:read sees only their own shifts (operator/date filters ignored); an admin-grade role (shift:cash) sees all operators with an operator + date-window filter. The server hard-scopes non-admins to req.user.username regardless of any ?operator= param (verified: an operator passing another's name returns scope:self, 0 rows). One screen, behaviour driven by the returned scope.

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_report at close. The operator is the logged-in user, carried in the event identity. ShiftService (apps/server/src/shift-service.ts).

    Superseded 2026-06-18: open-ness is now judged site-wide (currentOpenShift() — the most recent shift event on the whole chain), not per-operator. See "Site-wide single-open" above. openShiftFor(operator) survives only for close() (you close your own shift).

  • Close sums payment events in [startedAt, endedAt] by tender (cash vs. card, by payment time), appends the signed shift_z_report (totals + counts + window), then prints via the new generic PrinterDevice.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:false is 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). UI ShiftControl in 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.

Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18)

The Z-report's payment totals answer "how much did this shift take?" — but a manned booth also has a physical cash drawer that carries across shifts. The drawer is tracked as a running balance over the signed chain, so each shift knows what it inherited and what it should hand over.

The events:

  • A new signed cash_movement event: the admin loads or removes drawer cash, { amountMinor (signed: + load, − removal), reason, operator }. Admin-only (an operator takes payments but cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000) are both cash_movement events.
  • The existing payment events already add cash to the drawer (cash tender only; card never touches the drawer).

The math — drawer is a fold over the chain BY TIME, not by operator (a cash_movement is the admin's, not the shift operator's, so it can't key off identity):

expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
                   + Σ cash_movement amounts up to `at`

A shift's opening float = expectedDrawer(shiftStart) — i.e. everything that happened to the drawer before this shift's start mark. It is auto-inherited from the chain (no operator entry). The first shift ever opens at 0; the admin's load makes it 5000.

The Z-report at close reports the full drawer picture for the shift window [start, end]: openingFloat, cashTakenMinor (cash payments in-window), cashAddedMinor / cashRemovedMinor (movements in-window), and expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved. That expectedDrawer is exactly the next shift's opening float — the carry-over.

Worked example (the canonical scenario):

Step Event Drawer
Opening day admin cash_movement +5000 5000
Shift 1 takes 6500 cash payments 11500
Shift 1 closes Z: open 5000, took 6500, expected 11500 11500
Shift 2 opens opening float = 11500 (inherited) 11500
admin cash_movement −5000 withdrawal 6500
Shift 2 takes 4500 cash payments 11000
Shift 2 closes Z: open 11500, took 4500, removed 5000, expected 11000 11000
Shift 3 opens opening float = 11000 …

Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure is expected, not counted — the optional blind-count enhancement below would record the variance against it.

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

  • Drawer carry-over (decided 2026-06-18, building): opening float auto-inherits the prior shift's expected drawer; admin-only cash_movement events; Z-report reports the full drawer picture. See the Drawer balance section above.
  • 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).