feat(shift): site-wide single-open shift + booth money-path gate

A shift becomes a SITE-WIDE accountability period — at most one open at a
time — so every taking is unambiguously attributed to one operator. Login
stays decoupled from shifts (an operator can log in off-shift to review).

Backend:
- ShiftService.currentOpenShift()/requireOpenShift(); open() refuses when ANY
  shift is open and throws ShiftAlreadyOpenError{heldBy} (self vs. other).
- requireShift preHandler gates /api/pay, /api/exit, /api/voucher,
  /api/barrier/reopen → 409 {code:"no_shift"}; read-only lookups stay open.
- GET /api/shift/current returns site-wide {open:{startedAt,operator},isMine}.
- GET /api/events?since=<iso> for per-shift log scoping (db: re-export gte).

Frontend:
- Header shift button: open / close-mine / disabled-when-another-holds-it.
- Pay/exit modal gate banner (one-click open; "held by X" when another's);
  pay/exit/voucher disabled until this operator's shift is open.
- Active-Sessions barrier re-open gated the same way.
- Live feed scoped to the open shift's window; shared useShift() Query
  invalidated over the WS on shift_open/shift_z_report/cash_movement.
- sq/en strings for the control + gate.

Wiki: shift.md (site-wide single-open + gate; superseded per-operator note),
booth-console.md (header control + gate), log entry.

Verified: site-wide invariant + heldBy + handover + chain integrity on a
fresh migrated DB (11/11); db/server/web build clean.
This commit is contained in:
2026-06-18 12:13:17 +02:00
parent 48660d3ec8
commit 4e2e4feedb
19 changed files with 415 additions and 42 deletions
+28 -3
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-06-15
updated: 2026-06-18
status: open
---
@@ -21,6 +21,29 @@ is **no operator and no shift**; what replaces it is the pay station's **cash-co
[[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:
@@ -56,8 +79,10 @@ no variance gate, no manager override.
- 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`; a shift is **open** iff that operator's most recent shift event is a
`shift_open`. `ShiftService` (`apps/server/src/shift-service.ts`).
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