0074e82a2a
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
189 lines
12 KiB
Markdown
189 lines
12 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, domain, business, shifts, anti-fraud]
|
||
sources: []
|
||
updated: 2026-06-19
|
||
status: 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|fully-automated / unmanned]] 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|login token has no time expiry]] (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|RBAC]]): 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).
|