feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.
(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
a legit car).
New session:create permission (migration 0019 -> operator role, admin-
revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); boothExit client returns a
structured swap result.
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -191,6 +191,10 @@ export const en: Catalog = {
|
||||
fEvtAnomaly: "Anomaly",
|
||||
openPayExit: "Open pay / exit",
|
||||
openReopenBarrier: "Open — paid, awaiting barrier",
|
||||
issueEntry: "Issue ticket",
|
||||
issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)",
|
||||
issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?",
|
||||
issueEntryOk: "Entry ticket {{ticket}} issued.",
|
||||
exitedGrace: "exited · grace",
|
||||
exitedGraceLeft: "exited · {{time}}",
|
||||
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
||||
@@ -275,6 +279,8 @@ export const en: Catalog = {
|
||||
reason: {
|
||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
|
||||
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
|
||||
"exit.refused.closed": "Exit refused — session already closed",
|
||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||
@@ -284,6 +290,8 @@ export const en: Catalog = {
|
||||
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
||||
"exit.freeGrace": "Free entry-grace (no charge)",
|
||||
"exit.manualOpen": "Manual barrier open (human intervention)",
|
||||
"exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||
"exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})",
|
||||
"sub.refused.notFound": "Subscription refused — not found",
|
||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||
@@ -944,6 +952,10 @@ export const en: Catalog = {
|
||||
lookingUp: "looking up…",
|
||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||
swapTitle: "Possible ticket swap",
|
||||
swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.",
|
||||
swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.",
|
||||
swapOverride: "Override & release",
|
||||
subscription: "SUBSCRIPTION",
|
||||
plan: "Plan",
|
||||
prepaid: "PREPAID",
|
||||
|
||||
@@ -193,6 +193,10 @@ export const sq = {
|
||||
fEvtAnomaly: "Anomali",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openReopenBarrier: "Hap — paguar, pret barrierën",
|
||||
issueEntry: "Lësho biletë",
|
||||
issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)",
|
||||
issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?",
|
||||
issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.",
|
||||
exitedGrace: "doli · në afat",
|
||||
exitedGraceLeft: "doli · {{time}}",
|
||||
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
||||
@@ -278,6 +282,8 @@ export const sq = {
|
||||
reason: {
|
||||
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
||||
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
|
||||
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
|
||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||
@@ -287,6 +293,8 @@ export const sq = {
|
||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||
"exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||
"exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})",
|
||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||
@@ -960,6 +968,10 @@ export const sq = {
|
||||
lookingUp: "Duke kërkuar…",
|
||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||
swapTitle: "Mundësi ndërrimi biletash",
|
||||
swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.",
|
||||
swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.",
|
||||
swapOverride: "Anashkalo & lësho",
|
||||
subscription: "ABONIM",
|
||||
plan: "Plani",
|
||||
prepaid: "I PARAPAGUAR",
|
||||
|
||||
Reference in New Issue
Block a user