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:
+28
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user