8 Commits

Author SHA1 Message Date
julian b485e9870b feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
CI / check (push) Failing after 40s
Build & push images / images (push) Failing after 32s
Build desktop / desktop (push) Successful in 5m24s
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.

- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
  meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
  the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
  one pending crop at a time, operator's pick and camera's pick beside it, one button/key
  per vocabulary class + unusable + skip; stats per booth and per hashed operator
  (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
  category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
  neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
  (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
  step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
  secret per booth referenced from both the collector's token list and the booth's own
  stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
  built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:26:22 +02:00
julian ec44547122 docs(wiki): close the session's loose threads — bay printer question, vocabulary-in-code decision, training note, guard family, OQ #16 settled
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:37:57 +02:00
julian e67f0ccef0 feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.

- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
  plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
  downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
  margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
  pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
  bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
  400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
  orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
  CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
  .env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
  queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
  venue-modules As built, log. The collector is not built.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:33:43 +02:00
julian 78ca58d264 ui(carwash): mapping chips show the camera's canonical class ids, not translations
The vocabulary is a code constant (the model's contract), the site only maps it; a chip
reading VETURË beside a site category named Veture blurred exactly that (user, 2026-09-06).
Translation stays as the tooltip; a hint names where the list lives.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 21:25:08 +02:00
julian 20a3cb3e80 feat(vision): vehicle stage, phase A — YOLOX-S (Apache-2.0 ONNX) beside the plate recognizer
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.

- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
  decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
  else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
  a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
  costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
  (0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
  sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
  ~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
  and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 19:53:11 +02:00
julian 5e1395db18 feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).

- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
  reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
  client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
  the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
  carwash_config.vision_threshold (signed config_change when it moves), four vision
  columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
  offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
  one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
  the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
  reason prompt (user, 2026-09-06).

Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 13:37:34 +02:00
julian 50c18405b6 feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):

- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
  (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
  @parking/shared surface a followed job whose bundle grew past the role in a later
  release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
  (the union, nothing removed) and the editor lints it. Never a runtime union: the grid
  stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
  name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
  now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.

Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:52:47 +02:00
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
Closes the three known follow-ups of the Tills decision (venue-modules.md):

- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
  by payload till, other events by their owning module's till, everything else booth),
  applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
  panel and the booth feed (history + live pushes). The events route admits a role that
  holds a module feed permission without event:read and returns only that module's
  event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
  module; the ticket bucket excludes them (Bileta = parking only); printed
  "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
  back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
  one reading of the role field (the entry/booth loaders treated any non-booth role as
  an entry dispenser). Footer label "at wash desk".

Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:37:26 +02:00
78 changed files with 3667 additions and 153 deletions
+14 -1
View File
@@ -1,6 +1,6 @@
name: Build & push images name: Build & push images
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the # Build the SERVER (API + SPA), COLLECTOR (wash review) and VISION (ANPR) container images and push them to the
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage, # house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle). # main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
# Mirrors the house pattern (cf. trm/processor build.yml). See # Mirrors the house pattern (cf. trm/processor build.yml). See
@@ -13,6 +13,7 @@ on:
- 'apps/server/**' - 'apps/server/**'
- 'apps/web/**' - 'apps/web/**'
- 'apps/vision/**' - 'apps/vision/**'
- 'apps/collector/**'
- 'packages/**' - 'packages/**'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
@@ -100,6 +101,18 @@ jobs:
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
- name: Build & push COLLECTOR (wash review)
uses: docker/build-push-action@v5
with:
context: .
file: apps/collector/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
- name: Build & push VISION (ANPR) - name: Build & push VISION (ANPR)
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
+16
View File
@@ -0,0 +1,16 @@
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
# COLLECTOR_PORT=8090
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
# a site name. Generate tokens with: openssl rand -hex 32
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
COLLECTOR_REVIEWER_USER=reviewer
COLLECTOR_REVIEWER_PASS=REPLACE
+48
View File
@@ -0,0 +1,48 @@
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
# Built from the monorepo root (context: .) like the server image, so it shares the
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm fetch
FROM deps AS build
ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
RUN pnpm turbo run build --filter=@parking/collector
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
FROM node:22-alpine AS runtime
WORKDIR /app
ARG BUILD_VERSION=""
ENV BUILD_VERSION=$BUILD_VERSION
ENV NODE_ENV=production
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
RUN addgroup -S app && adduser -S -G app app
COPY --from=build --chown=app:app /deploy ./
ENV COLLECTOR_DATA_DIR=/data
ENV COLLECTOR_HOST=0.0.0.0
ENV COLLECTOR_PORT=8090
RUN mkdir -p /data && chown app:app /data
VOLUME ["/data"]
USER app
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
CMD ["node", "dist/index.js"]
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@parking/collector",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
"scripts": {
"build": "tsc -b",
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
"start": "node --env-file-if-exists=.env dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/multipart": "^9.2.1",
"@parking/shared": "workspace:*",
"better-sqlite3": "12.10.1",
"fastify": "5.8.5"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"@types/node": "25.9.3",
"tsx": "4.22.4",
"typescript": "6.0.3",
"vitest": "^4.1.9"
}
}
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildCollector, type CollectorApp } from "./app.js";
import { parseBoothTokens } from "./config.js";
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
let app: CollectorApp;
let dir: string;
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
const REVIEWER = { user: "julian", pass: "review-pass-123" };
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER }, { dbFile: ":memory:" });
await app.ready();
});
afterEach(async () => {
await app.close();
await rm(dir, { recursive: true, force: true });
});
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
function meta(over: Record<string, unknown> = {}) {
return {
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
image: { width: 320, height: 200, plateBlurred: true },
...over,
};
}
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
const b = "----collector-test";
const parts: Buffer[] = [];
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
parts.push(Buffer.from(`--${b}--\r\n`));
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
}
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
}
describe("ingest", () => {
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
const r = await ingest(meta());
expect(r.statusCode).toBe(201);
const row = app.collectorDb.get("item-1")!;
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
const again = await ingest(meta());
expect(again.statusCode).toBe(200);
expect(again.json()).toEqual({ ok: true, duplicate: true });
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
});
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
expect(app.collectorDb.stats().booths).toEqual([]);
});
});
describe("review + export", () => {
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
await ingest(meta());
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
const anon = await app.inject({ method: "GET", url: "/api/items" });
expect(anon.statusCode).toBe(401);
expect(anon.headers["www-authenticate"]).toContain("Basic");
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
expect(list.items[0].imagePath).toBeUndefined();
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
expect(img.statusCode).toBe(200);
expect(img.headers["content-type"]).toBe("image/jpeg");
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
const post = (id: string, label: string) =>
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
expect((await post("item-2", "sedan")).statusCode).toBe(200);
expect((await post("item-3", "unusable")).statusCode).toBe(200);
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
expect((await post("nope", "suv")).statusCode).toBe(404);
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats.booths).toEqual([
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2 },
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1 },
]);
expect(stats.operators).toEqual([
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
]);
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
expect(csv.statusCode).toBe(200);
const lines = csv.body.trim().split("\n");
expect(lines[0]).toBe("item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
expect(lines[1]).toContain('"item-1","booth-7","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
await post("item-4", "car");
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
});
});
describe("config", () => {
it("parses booth:token pairs and refuses short tokens", () => {
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { timingSafeEqual } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import multipart from "@fastify/multipart";
import { isVehicleClass } from "@parking/shared";
import type { CollectorConfig } from "./config.js";
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
import { reviewPage } from "./review-page.js";
// The collector — the far end of the booth's review outbox
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
// POST /ingest one package from one booth (bearer token per booth; idempotent)
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
// the volume, so the trainer on this host reads them directly)
// It deliberately has no fleet features and no path back into a booth.
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
interface IngestMeta {
v: number;
booth: string;
item: string;
order: string;
at: string;
operator: string;
operatorCategory: { id: string; name: string; classes?: string[] };
service: string;
vision: { class: string; confidence: number; categoryId: string | null };
downgraded: boolean;
image: { width: number; height: number; plateBlurred: boolean };
}
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
function str(v: unknown, max = 200): string | null {
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
}
/** Validate the meta part; returns a message on the first problem. */
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
const x = m as Record<string, unknown>;
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
const oc = x.operatorCategory as Record<string, unknown> | undefined;
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
const v = x.vision as Record<string, unknown> | undefined;
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
const im = x.image as Record<string, unknown> | undefined;
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
return { ok: true, meta: x as unknown as IngestMeta };
}
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
return ba.length === bb.length && timingSafeEqual(ba, bb);
}
export interface CollectorApp extends FastifyInstance {
collectorDb: CollectorDb;
}
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
app.collectorDb = db;
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
app.addHook("onClose", async () => db.close());
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
function boothForToken(req: FastifyRequest): string | null {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Bearer ")) return null;
const token = h.slice(7).trim();
let found: string | null = null;
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
return found;
}
/** HTTP Basic for the reviewer. */
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
const h = req.headers.authorization ?? "";
if (h.startsWith("Basic ")) {
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
const pass = rest.join(":");
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
}
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
}
app.get("/health", async () => {
const s = db.stats();
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
});
// --- Ingest (booths) -----------------------------------------------------------------
app.post("/ingest", async (req, reply) => {
const booth = boothForToken(req);
if (!booth) return reply.code(401).send({ error: "unauthorized" });
const claimed = req.headers["x-booth-id"];
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
let metaRaw: string | null = null;
let image: Buffer | null = null;
try {
for await (const part of req.parts()) {
if (part.type === "file" && part.fieldname === "image") {
image = await part.toBuffer();
} else if (part.type === "field" && part.fieldname === "meta") {
metaRaw = String(part.value);
}
}
} catch (err) {
const code = (err as { code?: string }).code;
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
}
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
let parsed: unknown;
try {
parsed = JSON.parse(metaRaw);
} catch {
return reply.code(400).send({ error: "meta is not JSON" });
}
const checked = checkMeta(parsed, booth);
if (!checked.ok) return reply.code(422).send({ error: checked.why });
const meta = checked.meta;
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
await writeFile(path.join(cfg.dataDir, rel), image);
db.insert({
id: meta.item,
booth,
orderRef: meta.order,
at: meta.at,
operatorRef: meta.operator,
operatorCategoryId: meta.operatorCategory.id,
operatorCategoryName: meta.operatorCategory.name,
operatorClasses: JSON.stringify(meta.operatorCategory.classes ?? []),
service: meta.service,
visionClass: meta.vision.class,
visionConfidence: meta.vision.confidence,
visionCategoryId: meta.vision.categoryId ?? null,
downgraded: meta.downgraded ? 1 : 0,
imageWidth: meta.image.width,
imageHeight: meta.image.height,
plateBlurred: meta.image.plateBlurred ? 1 : 0,
imagePath: rel,
receivedAt: new Date().toISOString(),
});
req.log.info(`ingest: ${booth} item ${meta.item} (${meta.vision.class} → ${meta.operatorCategory.name})`);
return reply.code(201).send({ ok: true });
});
// --- Review (the trusted person) -----------------------------------------------------
const page = reviewPage();
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
"/api/items",
{ preHandler: requireReviewer },
async (req) => {
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
},
);
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
const row = db.get(req.params.id);
if (!row) return reply.code(404).send({ error: "not found" });
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
});
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
const label = req.body?.label;
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
return publicItem(row!);
});
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
// --- Export (the training set) --------------------------------------------------------
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
const rows = db.labelled();
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
// gets a leading apostrophe — the category/service names are booth-supplied text and
// the reviewer will open this in a spreadsheet (CSV formula injection).
const q = (s: string | number | null) => {
let v = String(s ?? "");
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
return `"${v.replace(/"/g, '""')}"`;
};
const head = "item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
const lines = rows.map((r) =>
[r.id, r.booth, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
);
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
});
return app;
}
/** The row as the review screen sees it (no server paths). */
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
const { imagePath: _p, ...rest } = r;
return rest;
}
+36
View File
@@ -0,0 +1,36 @@
export interface CollectorConfig {
readonly host: string;
readonly port: number;
readonly dataDir: string;
/** boothId → bearer token. */
readonly boothTokens: ReadonlyMap<string, string>;
/** The single reviewer login; null = review screen and export refuse (503). */
readonly reviewer: { readonly user: string; readonly pass: string } | null;
}
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
export function parseBoothTokens(raw: string): Map<string, string> {
const out = new Map<string, string>();
for (const pair of raw.split(/[,\s]+/)) {
if (!pair) continue;
const i = pair.indexOf(":");
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
const booth = pair.slice(0, i).trim();
const token = pair.slice(i + 1).trim();
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
out.set(booth, token);
}
return out;
}
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
return {
host: env.COLLECTOR_HOST ?? "0.0.0.0",
port: Number(env.COLLECTOR_PORT ?? 8090),
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
reviewer: user && pass.length >= 8 ? { user, pass } : null,
};
}
+179
View File
@@ -0,0 +1,179 @@
import Database from "better-sqlite3";
import type { VehicleClass } from "@parking/shared";
// One table. Each row is one booth decision: what the camera saw, what the operator
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
// reads it straight off the volume.
export interface ItemRow {
id: string;
booth: string;
orderRef: string;
at: string;
operatorRef: string;
operatorCategoryId: string;
operatorCategoryName: string;
/** The vision classes the operator's category covers at that site (its mapping) — what
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
operatorClasses: string;
service: string;
visionClass: string;
visionConfidence: number;
visionCategoryId: string | null;
downgraded: number;
imageWidth: number;
imageHeight: number;
plateBlurred: number;
imagePath: string;
receivedAt: string;
reviewLabel: string | null; // a VehicleClass, or "unusable"
reviewedAt: string | null;
reviewer: string | null;
}
export type ReviewVerdict = VehicleClass | "unusable";
export class CollectorDb {
readonly #db: Database.Database;
constructor(file: string) {
this.#db = new Database(file);
this.#db.pragma("journal_mode = WAL");
this.#db.exec(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
booth TEXT NOT NULL,
order_ref TEXT NOT NULL,
at TEXT NOT NULL,
operator_ref TEXT NOT NULL,
operator_category_id TEXT NOT NULL,
operator_category_name TEXT NOT NULL,
operator_classes TEXT NOT NULL DEFAULT '[]',
service TEXT NOT NULL,
vision_class TEXT NOT NULL,
vision_confidence REAL NOT NULL,
vision_category_id TEXT,
downgraded INTEGER NOT NULL DEFAULT 0,
image_width INTEGER NOT NULL,
image_height INTEGER NOT NULL,
plate_blurred INTEGER NOT NULL,
image_path TEXT NOT NULL,
received_at TEXT NOT NULL,
review_label TEXT,
reviewed_at TEXT,
reviewer TEXT
);
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
`);
}
close(): void {
this.#db.close();
}
static #map(r: Record<string, unknown>): ItemRow {
return {
id: r.id as string,
booth: r.booth as string,
orderRef: r.order_ref as string,
at: r.at as string,
operatorRef: r.operator_ref as string,
operatorCategoryId: r.operator_category_id as string,
operatorCategoryName: r.operator_category_name as string,
operatorClasses: r.operator_classes as string,
service: r.service as string,
visionClass: r.vision_class as string,
visionConfidence: r.vision_confidence as number,
visionCategoryId: (r.vision_category_id as string | null) ?? null,
downgraded: r.downgraded as number,
imageWidth: r.image_width as number,
imageHeight: r.image_height as number,
plateBlurred: r.plate_blurred as number,
imagePath: r.image_path as string,
receivedAt: r.received_at as string,
reviewLabel: (r.review_label as string | null) ?? null,
reviewedAt: (r.reviewed_at as string | null) ?? null,
reviewer: (r.reviewer as string | null) ?? null,
};
}
get(id: string): ItemRow | null {
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return r ? CollectorDb.#map(r) : null;
}
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
this.#db
.prepare(
`INSERT INTO items (id, booth, order_ref, at, operator_ref, operator_category_id, operator_category_name,
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
image_width, image_height, plate_blurred, image_path, received_at)
VALUES (@id, @booth, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
)
.run(row);
}
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
const params: unknown[] = [];
if (booth) {
where.push("booth = ?");
params.push(booth);
}
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
const rows = this.#db
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
.all(...params, limit) as Record<string, unknown>[];
return rows.map((r) => CollectorDb.#map(r));
}
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
this.#db
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
.run(label, new Date().toISOString(), reviewer, id);
return this.get(id);
}
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
* reviewer's class fell inside the operator's chosen category (agree) or outside
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
stats(): {
booths: { booth: string; received: number; pending: number; reviewed: number }[];
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
} {
const booths = this.#db
.prepare(
`SELECT booth, COUNT(*) AS received,
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed
FROM items GROUP BY booth ORDER BY booth`,
)
.all() as { booth: string; received: number; pending: number; reviewed: number }[];
const reviewed = this.#db
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL")
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
for (const r of reviewed) {
const key = `${r.booth} ${r.operator_ref}`;
let o = ops.get(key);
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
o.reviewed += 1;
if (r.review_label === "unusable") o.unusable += 1;
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
else o.disagree += 1;
}
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
}
/** Reviewed, usable rows — the training set. */
labelled(): ItemRow[] {
const rows = this.#db
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
.all() as Record<string, unknown>[];
return rows.map((r) => CollectorDb.#map(r));
}
}
+16
View File
@@ -0,0 +1,16 @@
import { buildCollector } from "./app.js";
import { configFromEnv } from "./config.js";
const cfg = configFromEnv();
const app = await buildCollector(cfg);
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}`);
await app.listen({ host: cfg.host, port: cfg.port });
const stop = async () => {
await app.close();
process.exit(0);
};
process.on("SIGTERM", () => void stop());
process.on("SIGINT", () => void stop());
+117
View File
@@ -0,0 +1,117 @@
import { VEHICLE_CLASSES } from "@parking/shared";
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
// itself (no build step, no framework) — this is deliberately the whole UI.
export function reviewPage(): string {
const classes = JSON.stringify(VEHICLE_CLASSES);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wash review</title>
<style>
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
dt { color:var(--muted); }
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
button:hover { border-color:var(--amber); }
button.mono { font-family:ui-monospace, monospace; }
button.hint { border-color:var(--amber); }
button.unusable { color:var(--red); }
button.skip { color:var(--muted); }
.muted { color:var(--muted); }
.warn { color:var(--amber); }
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
</style>
</head>
<body>
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
<main>
<section class="card" id="item">
<p class="muted">Loading…</p>
</section>
<section class="card">
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
</section>
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
</main>
<script>
const CLASSES = ${classes};
const skipped = new Set();
let current = null;
async function api(path, init) {
const r = await fetch(path, init);
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
return r.json();
}
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
async function loadStats() {
const s = await api('/api/stats');
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
const tb = document.querySelector('#stats tbody');
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
}
async function next() {
const { items } = await api('/api/items?status=pending&limit=25');
current = items.find(i => !skipped.has(i.id)) || null;
const el = document.getElementById('item');
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
const it = current;
const opClasses = JSON.parse(it.operatorClasses || '[]');
el.innerHTML =
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
'<dl style="margin-top:.8rem">' +
'<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>' +
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>' +
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
'</dl>' +
'<div class="buttons" style="margin-top:.8rem">' +
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
'<button class="unusable" data-label="unusable">unusable</button>' +
'<button class="skip" data-skip="1">skip</button>' +
'</div>';
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
el.querySelector('button[data-skip]').addEventListener('click', skip);
}
async function verdict(label) {
if (!current) return;
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
await Promise.all([next(), loadStats()]);
}
function skip() { if (current) { skipped.add(current.id); next(); } }
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'u') verdict('unusable');
else if (e.key === 's') skip();
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
});
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
loadStats().catch(() => {});
</script>
</body>
</html>`;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
});
+12
View File
@@ -87,3 +87,15 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
# is never entitled to a module its Komodo stack env does not name. Required modules # is never entitled to a module its Komodo stack env does not name. Required modules
# (parking) are always on. See wiki/decisions/venue-modules.md. # (parking) are always on. See wiki/decisions/venue-modules.md.
#MODULES_ENTITLED=parking,validation #MODULES_ENTITLED=parking,validation
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
# CARWASH_REVIEW_URL=
# CARWASH_REVIEW_TOKEN=
# CARWASH_REVIEW_BOOTH_ID=
# CARWASH_REVIEW_INTERVAL_SEC=60
+1
View File
@@ -15,6 +15,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json apps/server/ COPY apps/server/package.json apps/server/
COPY apps/web/package.json apps/web/ COPY apps/web/package.json apps/web/
COPY apps/vision/package.json apps/vision/ COPY apps/vision/package.json apps/vision/
COPY apps/collector/package.json apps/collector/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
COPY packages/devices/package.json packages/devices/ COPY packages/devices/package.json packages/devices/
COPY packages/shared/package.json packages/shared/ COPY packages/shared/package.json packages/shared/
+3
View File
@@ -84,6 +84,7 @@ function fakeVision(opts: { enabled?: boolean; plate?: string; confidence?: numb
plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 }, plate: { text: opts.plate, confidence: opts.confidence ?? 0.99 },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
}; };
@@ -177,6 +178,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
})), })),
@@ -207,6 +209,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] }, plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [], plates: [],
lowConfidence: false, lowConfidence: false,
vehicle: null,
modelVersion: "test", modelVersion: "test",
tookMs: 1, tookMs: 1,
})), })),
+16
View File
@@ -222,6 +222,22 @@ export function requirePermission(...required: Permission[]) {
}; };
} }
/**
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
* two jobs legitimately share (a module's master data: the desk that works with it
* reads it under the module's own permission, Setup reads it under site:read).
*/
export function requireAnyPermission(...anyOf: Permission[]) {
return async (req: FastifyRequest, _reply: FastifyReply) => {
await req.jwtVerify();
assertCsrf(req);
refreshRole(req);
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
}
};
}
/** /**
* preHandler that requires a valid signed-in session but NO specific permission — * preHandler that requires a valid signed-in session but NO specific permission —
* for "about me" routes (/me, change own language) every authenticated user may * for "about me" routes (/me, change own language) every authenticated user may
+2 -1
View File
@@ -6,6 +6,7 @@ import {
type PrinterInstance, type PrinterInstance,
type ReceiptData, type ReceiptData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import { devicesByDirection } from "./device-resolve.js"; import { devicesByDirection } from "./device-resolve.js";
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+3 -3
View File
@@ -52,7 +52,7 @@ export interface ReadOutcome {
export interface PrinterStatusEvent { export interface PrinterStatusEvent {
readonly deviceId: string; // devices id readonly deviceId: string; // devices id
readonly driverId: string; readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
readonly status: PrinterStatus; readonly status: PrinterStatus;
} }
@@ -74,10 +74,10 @@ export interface DeviceStatusEvent {
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina": * chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay) * - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[]) * - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) * - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
* - undetermined: null (chip shows the category alone) * - undetermined: null (chip shows the category alone)
*/ */
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
readonly state: "ready" | "degraded" | "offline"; readonly state: "ready" | "degraded" | "offline";
readonly detail?: string; readonly detail?: string;
readonly checkedAt: string; // ISO-8601 readonly checkedAt: string; // ISO-8601
+2 -1
View File
@@ -64,7 +64,7 @@ export function localIsoWithOffset(tz: string, at = new Date()): string {
* - reader/camera → the direction inherited from its bound relay (entry/exit/both) * - reader/camera → the direction inherited from its bound relay (entry/exit/both)
* - access → entry/exit/both from its relays[]; "mixed" if it spans more * - access → entry/exit/both from its relays[]; "mixed" if it spans more
* than one direction; null if it declares none yet * than one direction; null if it declares none yet
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) * - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
*/ */
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] { function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
switch (row.category) { switch (row.category) {
@@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
const role = (row.config as { role?: string }).role; const role = (row.config as { role?: string }).role;
if (role === "booth-receipt") return "booth"; if (role === "booth-receipt") return "booth";
if (role === "entry-dispenser") return "lane"; if (role === "entry-dispenser") return "lane";
if (role === "wash-desk") return "wash";
return null; return null;
} }
default: default:
+2 -1
View File
@@ -9,6 +9,7 @@ import {
type PrinterInstance, type PrinterInstance,
type TicketData, type TicketData,
type TicketHeader, type TicketHeader,
printerRoleOf,
} from "@parking/devices"; } from "@parking/devices";
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
@@ -523,7 +524,7 @@ export class EntryFlow {
const driver = registry.get(row.driverId); const driver = registry.get(row.driverId);
if (!driver) continue; if (!driver) continue;
const cfg = row.config as Record<string, unknown>; const cfg = row.config as Record<string, unknown>;
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; const role = printerRoleOf(cfg);
try { try {
out.push({ out.push({
id: row.id, id: row.id,
+153 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing"; import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db"; import { deviceEvents, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { buildServer } from "../../server.js"; import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js"; import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
@@ -188,6 +188,17 @@ describe("orders", () => {
// A second lookup no longer carries the line (it's settled). // A second lookup no longer carries the line (it's settled).
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } }); const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
expect(again.json().chargeLines).toEqual([]); expect(again.json().chargeLines).toEqual([]);
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
const parking = payment.payload.parkingMinor as number;
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
}); });
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => { it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
@@ -429,6 +440,12 @@ describe("tills are gated by the module permission", () => {
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
}); });
const w = await login(app, washer.username, washer.password); const w = await login(app, washer.username, washer.password);
// The desk's category/service pickers come from the settings read — the job has no
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().categories.length).toBeGreaterThan(0);
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
// What the UI offers: only the wash till. // What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } }); const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]); expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
@@ -492,3 +509,138 @@ describe("a role reassignment takes effect without re-login", () => {
expect(me.roleId).toBe("wash-op"); expect(me.roleId).toBe("wash-op");
}); });
}); });
describe("a shift's activity log is per till", () => {
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await openSession("T-L");
await setPayAt(a, "bay");
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
const types = async (qs: string, auth: Auth = a) => {
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
expect(r.statusCode).toBe(200);
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
};
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
// its bay payment and its voucher — none of the booth's.
const wash = await types("&till=carwash");
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
const booth = await types("&till=booth");
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
// No till → everything (unchanged).
const all = await types("");
expect(all.length).toBe(wash.length + booth.length);
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
// A wash operator holds carwash:read but not event:read: the log opens for them
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
const w = await login(app, washer.username, washer.password);
const mine = await types("&till=carwash", w);
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
// A role with neither event:read nor any module feed permission reads nothing.
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
const c = await login(app, clerk.username, clerk.password);
expect((await app.inject({ method: "GET", url: "/api/events", headers: { cookie: c.cookie } })).statusCode).toBe(403);
});
});
describe("vision category — advisory, flagged, never authoritative", () => {
/** What snapshot.ts records when vision classifies the entry frame. */
function seeVehicle(identity: string, bodyType: string, bodyConfidence: number) {
db.insert(deviceEvents).values({
id: `read-${identity}-${bodyType}`, deviceId: "cam-1", category: "camera", kind: "read",
detail: { identity, direction: "entry", bodyType, bodyConfidence, snapshotId: "snap-1", source: "entry-exit-snapshot" },
occurredAt: new Date().toISOString(),
}).run();
}
async function mapClasses(a: Auth, ids: { car: string; suv: string }) {
const cur = (await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json();
const r = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: cur.categories.map((c: { id: string }) => ({ ...c, visionClasses: c.id === ids.suv ? ["suv", "pickup"] : c.id === ids.car ? ["car", "sedan", "hatchback"] : [] })),
visionThreshold: 0.75,
},
});
expect(r.statusCode).toBe(200);
return r.json();
}
it("Setup maps the vocabulary onto site categories; the lookup suggests the mapped category", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
const saved = await mapClasses(a, ids);
expect(saved.categories.find((c: { id: string }) => c.id === ids.suv).visionClasses).toEqual(["suv", "pickup"]);
expect(saved.visionThreshold).toBe(0.75);
expect((await events(a)).some((e) => e.type === "config_change" && e.payload.setting === "carwash.visionThreshold")).toBe(true);
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { categories: [{ id: ids.car, name: "Car", visionClasses: ["spaceship"] }] } });
expect(bad.statusCode).toBe(400);
await openSession("T-V1");
seeVehicle("T-V1", "suv", 0.91);
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
expect(look.suggestedCategoryId).toBe(ids.suv);
// Unmapped class → shown, nothing suggested.
await openSession("T-V2");
seeVehicle("T-V2", "bus", 0.99);
const look2 = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V2", headers: { cookie: a.cookie } })).json();
expect(look2.vision.bodyType).toBe("bus");
expect(look2.suggestedCategoryId).toBeNull();
});
it("a confident downgrade signs an anomaly with both categories and the snapshot; equal, upgrade or unsure reads do not; the order is never blocked", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await mapClasses(a, ids);
const order = async (identity: string, categoryId: string) => {
const r = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity, categoryId, serviceId: ids.std } });
expect(r.statusCode).toBe(201);
return r.json();
};
// Camera: SUV (0.91) — operator picks Car (cheaper) → flagged, recorded, still created.
await openSession("T-D1"); seeVehicle("T-D1", "suv", 0.91);
const down = await order("T-D1", ids.car);
expect(down).toMatchObject({ visionClass: "suv", visionConfidence: 0.91, visionCategoryId: ids.suv, categoryId: ids.car });
expect(down.downgradeEventId).toBeTruthy();
const flag = (await events(a)).find((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")!;
expect(flag).toBeTruthy();
expect(flag.payload).toMatchObject({
visionClass: "suv", visionCategoryName: "SUV", chosenCategoryName: "Car", operator: "boss",
visionPriceMinor: 70000, chosenPriceMinor: 50000, snapshotId: "snap-1",
});
// Same category as the camera → nothing.
await openSession("T-D2"); seeVehicle("T-D2", "suv", 0.91);
expect((await order("T-D2", ids.suv)).downgradeEventId).toBeNull();
// Upgrade (camera Car, operator SUV) → recorded on the order, no anomaly.
await openSession("T-D3"); seeVehicle("T-D3", "sedan", 0.95);
const up = await order("T-D3", ids.suv);
expect(up).toMatchObject({ visionClass: "sedan", visionCategoryId: ids.car, downgradeEventId: null });
// Below the site threshold → shown, never flagged.
await openSession("T-D4"); seeVehicle("T-D4", "suv", 0.6);
expect((await order("T-D4", ids.car)).downgradeEventId).toBeNull();
// No read at all → nulls.
await openSession("T-D5");
expect(await order("T-D5", ids.car)).toMatchObject({ visionClass: null, visionCategoryId: null, downgradeEventId: null });
expect((await events(a)).filter((e) => e.type === "anomaly" && e.payload.reasonCode === "carwash.categoryDowngrade")).toHaveLength(1);
});
});
+11 -2
View File
@@ -1,4 +1,5 @@
import type { ServerModule } from "../index.js"; import type { ServerModule } from "../index.js";
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
import { carwashRoutes } from "./routes.js"; import { carwashRoutes } from "./routes.js";
import { CarwashService } from "./service.js"; import { CarwashService } from "./service.js";
@@ -10,10 +11,18 @@ import { CarwashService } from "./service.js";
export const carwashModule: ServerModule = { export const carwashModule: ServerModule = {
id: "carwash", id: "carwash",
async register(app, deps) { async register(app, deps) {
const service = new CarwashService(deps, app.log); // The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
// queueing at all. One-way, background, never on the intake path.
const cfg = reviewUploadConfigFromEnv();
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
outbox.start();
app.addHook("onClose", async () => outbox.stop());
const service = new CarwashService(deps, app.log, outbox);
// A wash ordered with payAt = "booth" is a charge line on the parking settlement; // A wash ordered with payAt = "booth" is a charge line on the parking settlement;
// the core calls back after the payment is signed so the order is marked paid. // the core calls back after the payment is signed so the order is marked paid.
deps.payStation.registerChargeProvider(service.chargeProvider()); deps.payStation.registerChargeProvider(service.chargeProvider());
await carwashRoutes(app, deps, service); await carwashRoutes(app, deps, service, outbox);
}, },
}; };
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { createTestDb } from "@parking/db/testing";
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
// backoff, never blocking the wash, never naming the site.
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
async function frame(): Promise<Buffer> {
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
.composite([
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
])
.jpeg()
.toBuffer();
}
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
* blurred plate drops far below 255 as the red bleeds in. */
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
let sum = 0;
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
return sum / (data.length / info.channels);
}
describe("makeReviewCrop", () => {
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
const shot = await frame();
const crop = await makeReviewCrop(shot, CAR, PLATE);
expect(crop.plateBlurred).toBe(true);
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
expect(crop.width).toBeGreaterThanOrEqual(228);
expect(crop.width).toBeLessThanOrEqual(236);
expect(crop.height).toBeGreaterThanOrEqual(135);
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
// frame is white, the review crop is the red bleeding in.
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
// Without a plate box: same crop, nothing blurred.
const noPlate = await makeReviewCrop(shot, CAR, null);
expect(noPlate.plateBlurred).toBe(false);
// A big frame is capped to the max edge.
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
expect(Math.max(capped.width, capped.height)).toBe(640);
});
});
describe("config + pseudonyms", () => {
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
expect(reviewUploadConfigFromEnv({})).toBeNull();
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
});
});
describe("queue + drain", () => {
let db: Db;
let close: () => void;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
});
afterEach(() => close());
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60 };
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
async function seed(): Promise<void> {
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
db.insert(carwashOrders).values({
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
}).run();
}
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
await seed();
const calls: { url: string; init: RequestInit }[] = [];
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
return new Response("ok", { status: 200 });
});
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
expect(await ob.enqueue(item, read)).toBe(true);
const row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row.status).toBe("queued");
expect(row.image!.length).toBeGreaterThan(500);
expect(row.payload).toMatchObject({ v: 1, booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
expect(calls).toHaveLength(1);
expect(calls[0]!.url).toBe(cfg.url);
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
const form = calls[0]!.init.body as FormData;
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
expect((form.get("image") as File).type).toBe("image/jpeg");
const after = db.select().from(carwashReviewOutbox).all()[0]!;
expect(after.status).toBe("sent");
expect(after.image).toBeNull();
expect(after.sentAt).toBeTruthy();
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
});
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
await seed();
let status = 503;
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
await ob.enqueue(item, read);
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
let row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
// Not due yet → untouched.
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
// Due again: a network error defers too; a 422 abandons.
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
status = 0;
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
status = 422;
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
row = db.select().from(carwashReviewOutbox).all()[0]!;
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
expect(row.image).toBeNull();
// A voided order is not a sample.
status = 200;
await ob.enqueue({ ...item, orderId: "o-1" }, read);
db.update(carwashOrders).set({ status: "void" }).run();
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
// Expired items are abandoned without a request.
await ob.enqueue(item, read);
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
db.update(carwashOrders).set({ status: "open" }).run();
const before = fetchFn.mock.calls.length;
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
expect(fetchFn.mock.calls.length).toBe(before);
expect(ob.status().failed).toBe(3);
// No vehicle box, no snapshot, or upload off → nothing queued.
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
});
});
import { eq } from "@parking/db";
describe("through the app", () => {
let db: Db;
let close: () => void;
let app: FastifyInstance;
const saved = { ...process.env };
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
process.env.CARWASH_REVIEW_TOKEN = "tok";
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID"]) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const a = await login(app, username, password);
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
seedTariff(db);
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
const cat = s.categories[0].id, svc = s.services[0].id;
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
db.insert(deviceEvents).values({
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
occurredAt: new Date().toISOString(),
}).run();
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
expect(order.statusCode).toBe(201);
// Enqueue is fire-and-forget: give the crop a moment.
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0 });
});
});
@@ -0,0 +1,330 @@
import { createHash, randomUUID } from "node:crypto";
import sharp from "sharp";
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
import type { NormBox, VehicleRead } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
//
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
// the operator may err or cheat). So every wash order that has a vehicle read queues a
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
// The reviewer's verdict becomes the training label for the body-type classifier (phase
// B) and, per operator, the honest-mistake / fraud rate.
//
// Rules that shape this file:
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
// a background loop drains the queue when the private overlay (Netbird) is up, with
// backoff, and gives up loudly after EXPIRE_DAYS.
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
// signed ledger stays the only record of what happened at the wash.
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
// the operator as a keyed hash. The mapping back to people and places stays with the
// reviewer, off the collector.
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
// booth can do nothing at the collector but this one POST.
export interface ReviewUploadConfig {
/** The collector's ingest URL (reachable only over the overlay). */
readonly url: string;
/** Per-booth bearer token. */
readonly token: string;
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
readonly boothId: string;
readonly intervalSec: number;
}
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
if (!url || !token || !boothId) return null;
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
return { url, token, boothId, intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60 };
}
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
* enough that a day of washes is a few megabytes. */
export const CROP_MAX_EDGE = 640;
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
const CROP_MARGIN = 0.08;
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
* should not resurface a fortnight of crops in one burst. */
export const EXPIRE_DAYS = 14;
/** Backoff: 1 min · 2^attempts, capped. */
const BACKOFF_BASE_MS = 60_000;
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
const UPLOAD_TIMEOUT_MS = 20_000;
/** What one order contributes to the package (the service hands this over at intake). */
export interface ReviewItemInput {
readonly orderId: string;
readonly createdAt: string;
readonly createdBy: string;
readonly categoryId: string;
readonly categoryName: string;
/** The vision classes the chosen category covers at this site (its mapping) — lets the
* reviewer's class be judged against the operator's category without the site's setup. */
readonly categoryClasses: readonly string[];
readonly serviceName: string;
readonly visionCategoryId: string | null;
readonly downgraded: boolean;
}
/**
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
*/
export async function makeReviewCrop(
snapshotBytes: Buffer,
box: NormBox,
plateBox: NormBox | null | undefined,
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
const meta = await img.metadata();
const W = meta.width ?? 0;
const H = meta.height ?? 0;
if (!W || !H) throw new Error("snapshot has no dimensions");
const px = (b: NormBox) => ({
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
});
const v = px(box);
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
const left = Math.max(0, v.left - mw);
const top = Math.max(0, v.top - mh);
const right = Math.min(W, v.right + mw);
const bottom = Math.min(H, v.bottom + mh);
const width = right - left;
const height = bottom - top;
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
let crop = img.clone().extract({ left, top, width, height });
let plateBlurred = false;
if (plateBox) {
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
const p = px(plateBox);
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
const pl = Math.max(0, p.left - pad - left);
const pt = Math.max(0, p.top - pad - top);
const pr = Math.min(width, p.right + pad - left);
const pb = Math.min(height, p.bottom + pad - top);
if (pr - pl >= 2 && pb - pt >= 2) {
const region = await sharp(await crop.clone().toBuffer())
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
.blur(Math.max(6, Math.round((pr - pl) / 6)))
.toBuffer();
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
plateBlurred = true;
}
}
const out = await crop
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 85, mozjpeg: true })
.toBuffer({ resolveWithObject: true });
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
}
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
* meaningless anywhere else. */
export function operatorRef(boothId: string, username: string): string {
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
}
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
export interface OutboxStatus {
readonly enabled: boolean;
readonly boothId: string | null;
readonly queued: number;
readonly sent: number;
readonly failed: number;
readonly lastSentAt: string | null;
readonly lastError: string | null;
}
export class ReviewOutbox {
readonly #db: Db;
readonly #logger: FastifyBaseLogger;
readonly #cfg: ReviewUploadConfig | null;
readonly #fetch: FetchLike;
#timer: NodeJS.Timeout | null = null;
#draining = false;
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
this.#db = db;
this.#logger = logger;
this.#cfg = cfg;
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
}
get enabled(): boolean {
return this.#cfg != null;
}
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
* intake path; every failure is logged, none is thrown. Skipped when there is no
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
* sample) or when upload is not configured (an unbounded queue nobody drains). */
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
if (!this.#cfg) return false;
if (!read.box || !read.snapshotId) return false;
try {
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
if (!snap) {
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — order ${item.orderId} not queued`);
return false;
}
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
const id = randomUUID();
const payload = {
v: 1,
booth: this.#cfg.boothId,
item: id,
order: item.orderId,
at: item.createdAt,
operator: operatorRef(this.#cfg.boothId, item.createdBy),
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
service: item.serviceName,
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
downgraded: item.downgraded,
image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred },
};
this.#db
.insert(carwashReviewOutbox)
.values({ id, orderId: item.orderId, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
.run();
return true;
} catch (err) {
this.#logger.warn(`carwash review: could not queue order ${item.orderId}: ${(err as Error).message}`);
return false;
}
}
start(): void {
if (!this.#cfg || this.#timer) return;
const tick = () => {
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
};
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
this.#timer.unref?.();
setTimeout(tick, 5_000).unref?.();
}
stop(): void {
if (this.#timer) clearInterval(this.#timer);
this.#timer = null;
}
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
const tally = { sent: 0, failed: 0, deferred: 0 };
if (!this.#cfg || this.#draining) return tally;
this.#draining = true;
try {
const now = new Date().toISOString();
const due = this.#db
.select()
.from(carwashReviewOutbox)
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
.orderBy(asc(carwashReviewOutbox.createdAt))
.limit(limit)
.all();
for (const row of due) {
const outcome = await this.#send(row);
tally[outcome] += 1;
}
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
} finally {
this.#draining = false;
}
return tally;
}
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
const cfg = this.#cfg!;
const ageMs = Date.now() - Date.parse(row.createdAt);
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
// A wash voided before delivery is not a sample (and not a decision to review).
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
if (order?.status === "void") return this.#fail(row, "order voided");
if (!row.image) return this.#fail(row, "image missing");
const form = new FormData();
form.set("meta", JSON.stringify(row.payload));
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await this.#fetch(cfg.url, {
method: "POST",
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
body: form,
signal: ac.signal,
});
if (res.ok) {
this.#db
.update(carwashReviewOutbox)
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
return "sent";
}
// The collector refused the package itself → no retry will help.
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
return this.#defer(row, `HTTP ${res.status}`);
} catch (err) {
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
} finally {
clearTimeout(t);
}
}
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
this.#db
.update(carwashReviewOutbox)
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
return "failed";
}
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
const attempts = row.attempts + 1;
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
this.#db
.update(carwashReviewOutbox)
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
.where(eq(carwashReviewOutbox.id, row.id))
.run();
return "deferred";
}
status(): OutboxStatus {
const count = (s: "queued" | "sent" | "failed") =>
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
const lastErr = this.#db
.select({ e: carwashReviewOutbox.lastError })
.from(carwashReviewOutbox)
.where(sql`${carwashReviewOutbox.lastError} is not null`)
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
.limit(1)
.get()?.e ?? null;
return {
enabled: this.enabled,
boothId: this.#cfg?.boothId ?? null,
queued: count("queued"),
sent: count("sent"),
failed: count("failed"),
lastSentAt: lastSent,
lastError: lastErr,
};
}
}
+11 -3
View File
@@ -1,9 +1,10 @@
import type { FastifyInstance, FastifyReply } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import type { Tender } from "@parking/shared"; import type { Tender } from "@parking/shared";
import { requirePermission } from "../../auth.js"; import { requireAnyPermission, requirePermission } from "../../auth.js";
import { requireModule } from "../../modules.js"; import { requireModule } from "../../modules.js";
import { NoShiftOpenError } from "../../shift-service.js"; import { NoShiftOpenError } from "../../shift-service.js";
import type { ServerModuleDeps } from "../index.js"; import type { ServerModuleDeps } from "../index.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js"; import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate // HTTP surface of the Car Wash module. Every route is behind the venue-module gate
@@ -27,15 +28,22 @@ function sendError(reply: FastifyReply, err: unknown): FastifyReply {
throw err; throw err;
} }
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> { export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
const moduleOn = requireModule(deps.db, "carwash"); const moduleOn = requireModule(deps.db, "carwash");
const settingsRead = [moduleOn, requirePermission("site:read")]; // The price list is the desk's working data as much as Setup's: the wash operator
// reads it under the module's own permission (the Wash operator job holds no site:*).
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
const settingsWrite = [moduleOn, requirePermission("site:update")]; const settingsWrite = [moduleOn, requirePermission("site:update")];
const read = [moduleOn, requirePermission("carwash:read")]; const read = [moduleOn, requirePermission("carwash:read")];
const create = [moduleOn, requirePermission("carwash:create")]; const create = [moduleOn, requirePermission("carwash:create")];
const update = [moduleOn, requirePermission("carwash:update")]; const update = [moduleOn, requirePermission("carwash:update")];
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings()); app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
// The review outbox's health (Setup → Car wash): how many decisions wait for the
// reviewer, how many went, the last error. Site admin's read.
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null },
);
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => { app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
try { try {
+140 -7
View File
@@ -23,10 +23,17 @@ import {
type CarwashOrderView, type CarwashOrderView,
type CarwashSettingsView, type CarwashSettingsView,
type ChargeLine, type ChargeLine,
CARWASH_VISION_THRESHOLD_DEFAULT,
isVehicleClass,
reasonPayload,
type VehicleClass,
type VehicleRead,
type Tender, type Tender,
type TillId, type TillId,
} from "@parking/shared"; } from "@parking/shared";
import type { EventLog } from "../../event-log.js"; import type { EventLog } from "../../event-log.js";
import { vehicleForIdentity } from "../../plate-lookup.js";
import type { ReviewOutbox } from "./review-outbox.js";
import { effectiveModulesFor } from "../../modules.js"; import { effectiveModulesFor } from "../../modules.js";
import type { ChargeProvider, PayStation } from "../../pay-station.js"; import type { ChargeProvider, PayStation } from "../../pay-station.js";
import type { ShiftService } from "../../shift-service.js"; import type { ShiftService } from "../../shift-service.js";
@@ -57,11 +64,12 @@ export class CarwashError extends Error {
} }
export interface SettingsBody { export interface SettingsBody {
categories?: { id?: string; name?: string; active?: boolean }[]; categories?: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[];
services?: { id?: string; name?: string; active?: boolean }[]; services?: { id?: string; name?: string; active?: boolean }[];
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[]; prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
/** Where wash money is taken at this site (site-level policy). */ /** Where wash money is taken at this site (site-level policy). */
payAt?: unknown; payAt?: unknown;
visionThreshold?: unknown;
} }
export interface CreateOrderInput { export interface CreateOrderInput {
@@ -83,6 +91,10 @@ export interface TicketLookup {
enteredAt: string | null; enteredAt: string | null;
currency: string | null; currency: string | null;
orders: CarwashOrderView[]; orders: CarwashOrderView[];
/** What the camera saw at entry (advisory) and the category the site mapping
* suggests for it — the desk pre-selects it; the operator may change it. */
vision: VehicleRead | null;
suggestedCategoryId: string | null;
} }
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -105,13 +117,15 @@ export class CarwashService {
readonly #pay: PayStation; readonly #pay: PayStation;
readonly #shift: ShiftService; readonly #shift: ShiftService;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
readonly #outbox: ReviewOutbox | null;
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) { constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
this.#db = deps.db; this.#db = deps.db;
this.#log = deps.eventLog; this.#log = deps.eventLog;
this.#pay = deps.payStation; this.#pay = deps.payStation;
this.#shift = deps.shiftService; this.#shift = deps.shiftService;
this.#logger = logger; this.#logger = logger;
this.#outbox = outbox;
} }
#enabled(): boolean { #enabled(): boolean {
@@ -127,7 +141,7 @@ export class CarwashService {
.where(isNull(carwashCategories.deletedAt)) .where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name)) .orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all() .all()
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active })); .map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active, visionClasses: r.visionClasses.filter(isVehicleClass) }));
const services = this.#db const services = this.#db
.select() .select()
.from(carwashServices) .from(carwashServices)
@@ -142,7 +156,7 @@ export class CarwashService {
.all() .all()
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId)) .filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor })); .map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt() }; return { categories, services, prices, currency: this.#currency(), payAt: this.payAt(), visionThreshold: this.visionThreshold() };
} }
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */ /** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
@@ -151,6 +165,25 @@ export class CarwashService {
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT; return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
} }
/** Confidence floor for a vision class to flag a category downgrade (site config). */
visionThreshold(): number {
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT;
}
/** The category the site mapping suggests for a vision class (first active category
* listing it, in display order), or null when unmapped. */
#categoryForClass(cls: VehicleClass): { id: string; name: string } | null {
const rows = this.#db
.select()
.from(carwashCategories)
.where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.all();
const hit = rows.find((r) => r.active && r.visionClasses.includes(cls));
return hit ? { id: hit.id, name: hit.name } : null;
}
/** The site's currency = the active tariff's (the wash is priced in the same money /** The site's currency = the active tariff's (the wash is priced in the same money
* the booth takes). null when no tariff is published yet. */ * the booth takes). null when no tariff is published yet. */
#currency(): string | null { #currency(): string | null {
@@ -171,7 +204,7 @@ export class CarwashService {
const now = new Date().toISOString(); const now = new Date().toISOString();
const upsertList = ( const upsertList = (
table: typeof carwashCategories | typeof carwashServices, table: typeof carwashCategories | typeof carwashServices,
items: { id?: string; name?: string; active?: boolean }[] | undefined, items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
label: string, label: string,
): string[] => { ): string[] => {
if (items === undefined) { if (items === undefined) {
@@ -190,11 +223,19 @@ export class CarwashService {
while (seen.has(id)) id = `${id}-${sort}`; while (seen.has(id)) id = `${id}-${sort}`;
seen.add(id); seen.add(id);
const active = it.active !== false; const active = it.active !== false;
// Vision mapping lives on CATEGORIES only; absent = keep what the row has.
let visionClasses: string[] | undefined;
if (table === carwashCategories && it.visionClasses !== undefined) {
if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) {
throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`);
}
visionClasses = [...new Set(it.visionClasses as string[])];
}
const existing = this.#db.select().from(table).where(eq(table.id, id)).get(); const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
if (existing) { if (existing) {
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null }).where(eq(table.id, id)).run(); this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
} else { } else {
this.#db.insert(table).values({ id, name, sortOrder: sort, active }).run(); this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
} }
keep.push(id); keep.push(id);
sort += 1; sort += 1;
@@ -260,6 +301,25 @@ export class CarwashService {
}); });
} }
} }
if (body.visionThreshold !== undefined) {
const v = Number(body.visionThreshold);
if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1");
const prev = this.visionThreshold();
if (v !== prev) {
this.#db
.insert(carwashConfig)
.values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor })
.onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } })
.run();
await this.#log.append({
type: "config_change",
source: "manual",
identity: "module:carwash",
payload: { setting: "carwash.visionThreshold", value: v, prev, operator: actor },
});
}
}
return this.settings(); return this.settings();
} }
@@ -289,6 +349,10 @@ export class CarwashService {
validationEventId: r.validationEventId, validationEventId: r.validationEventId,
voidBy: r.voidBy, voidBy: r.voidBy,
voidReason: r.voidReason, voidReason: r.voidReason,
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
visionConfidence: r.visionConfidence,
visionCategoryId: r.visionCategoryId,
downgradeEventId: r.downgradeEventId,
}; };
} }
@@ -335,6 +399,7 @@ export class CarwashService {
lookup(identity: string): TicketLookup { lookup(identity: string): TicketLookup {
const id = identity.trim(); const id = identity.trim();
const s = this.#pay.lookup(id); const s = this.#pay.lookup(id);
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
return { return {
identity: id, identity: id,
found: s.found, found: s.found,
@@ -344,6 +409,8 @@ export class CarwashService {
enteredAt: s.enteredAt, enteredAt: s.enteredAt,
currency: s.currency, currency: s.currency,
orders: this.#ordersFor(id), orders: this.#ordersFor(id),
vision,
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
}; };
} }
@@ -383,6 +450,51 @@ export class CarwashService {
const currency = s.currency ?? this.#pay.activeCurrency(); const currency = s.currency ?? this.#pay.activeCurrency();
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff"); if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
// Vision, advisory: what the camera saw at entry and the category the site maps it
// to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped
// one for this service, with the read above the site threshold — is signed as an
// anomaly for the reviewer (both categories, operator, snapshot). Recorded only:
// never blocks, no reason prompt (user, 2026-09-06).
const vision = vehicleForIdentity(this.#db, identity);
const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null;
let downgradeEventId: string | null = null;
if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) {
const visionPrice = this.#db
.select()
.from(carwashPrices)
.where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id)))
.get();
if (visionPrice && visionPrice.priceMinor > price.priceMinor) {
const ev = await this.#log.append({
type: "anomaly",
source: "manual",
identity,
payload: {
...reasonPayload("carwash.categoryDowngrade", {
visionClass: vision.bodyType,
visionCategory: visionCategory.name,
operator: input.actor,
chosenCategory: category.name,
}),
sessionRef: identity,
visionClass: vision.bodyType,
visionConfidence: vision.confidence,
visionCategoryId: visionCategory.id,
visionCategoryName: visionCategory.name,
chosenCategoryId: category.id,
chosenCategoryName: category.name,
serviceName: service.name,
visionPriceMinor: visionPrice.priceMinor,
chosenPriceMinor: price.priceMinor,
currency,
snapshotId: vision.snapshotId,
operator: input.actor,
},
});
downgradeEventId = ev.id;
}
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const row: CarwashOrderRow = { const row: CarwashOrderRow = {
id: randomUUID(), id: randomUUID(),
@@ -408,8 +520,29 @@ export class CarwashService {
voidAt: null, voidAt: null,
voidBy: null, voidBy: null,
voidReason: null, voidReason: null,
visionClass: vision?.bodyType ?? null,
visionConfidence: vision?.confidence ?? null,
visionCategoryId: visionCategory?.id ?? null,
downgradeEventId,
}; };
this.#db.insert(carwashOrders).values(row).run(); this.#db.insert(carwashOrders).values(row).run();
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
if (vision && this.#outbox?.enabled) {
void this.#outbox.enqueue(
{
orderId: row.id,
createdAt: now,
createdBy: input.actor,
categoryId: category.id,
categoryName: category.name,
categoryClasses: category.visionClasses,
serviceName: service.name,
visionCategoryId: visionCategory?.id ?? null,
downgraded: downgradeEventId != null,
},
vision,
);
}
await this.#log.append({ await this.#log.append({
type: "carwash_order", type: "carwash_order",
source: "manual", source: "manual",
+33
View File
@@ -1,4 +1,5 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db"; import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it // READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the // lives in the unsigned, prunable `device_events` (kind="read") stream written by the
@@ -23,6 +24,38 @@ interface ReadDetail {
plate?: string; plate?: string;
confidence?: number; confidence?: number;
direction?: string; direction?: string;
bodyType?: string;
bodyConfidence?: number;
snapshotId?: string;
vehicleBox?: unknown;
plateBox?: unknown;
}
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
* preference as the plate (entry over exit, newest first). Null when vision never
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
let fallback: VehicleRead | null = null;
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
const v: VehicleRead = {
bodyType: d.bodyType,
confidence: d.bodyConfidence,
snapshotId: d.snapshotId ?? null,
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
};
if (d.direction === "entry") return v;
if (!fallback) fallback = v;
}
return fallback;
} }
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */ /** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
+44 -9
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db"; import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared"; import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
import { effectiveModulesFor } from "../modules.js";
import { enrichEvents } from "../event-enrich.js"; import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
@@ -15,25 +16,59 @@ export async function eventRoutes(
db: Db, db: Db,
eventLog: EventLog, eventLog: EventLog,
): Promise<void> { ): Promise<void> {
// Reading the log (the audit trail). // Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
const guard = requirePermission("event:read"); // may still hold a module's feed permission (a wash operator's `carwash:read`) and
// then reads ONLY that module's event types — the same rule the live socket applies
// (feedPermissionFor; venue-modules.md §Permissions matrix, move 3).
/** The event types a role may read, or null for "everything" (event:read). Empty =
* the role reads nothing → 403 at the route. */
function readableTypes(roleId: string): LedgerEventType[] | null {
if (roleHasPermissions(roleId, ["event:read"])) return null;
const effective = effectiveModulesFor(db);
const out: LedgerEventType[] = [];
for (const m of MODULES) {
if (!m.feedPermission || !effective.includes(m.id)) continue;
if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes);
}
return out;
}
/** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of
* the module owning the event type, else the booth. Computed in the query so the
* page limit applies AFTER the till filter (a shift's window can hold thousands of
* device events). */
const tillExpr = (() => {
const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map(
(m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`,
);
return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`;
})();
// Recent events, newest first. `limit` caps the page (default 100, max 1000). // Recent events, newest first. `limit` caps the page (default 100, max 1000).
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes // Optional `since` (ISO) scopes to events at/after that instant — the booth passes
// the current shift's start so the live feed shows ONLY this shift's activity. An // the current shift's start so the live feed shows ONLY this shift's activity. An
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a // optional `until` (ISO) closes the upper bound — the shift-history screen passes a
// selected shift's [start, end] to show just that shift's signed activity log. // selected shift's [start, end] to show just that shift's signed activity log.
// (logs are per-shift, not all history). See wiki/concepts/shift.md. // (logs are per-shift, not all history). An optional `till` keeps only that till's
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>( // activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and
// vice versa. See wiki/concepts/shift.md §Tills.
app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>(
"/api/events", "/api/events",
{ preHandler: guard }, { preHandler: requireAuth },
async (req) => { async (req, reply) => {
const types = readableTypes(req.user?.roleId ?? "");
if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" });
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const since = (req.query.since ?? "").trim(); const since = (req.query.since ?? "").trim();
const until = (req.query.until ?? "").trim(); const until = (req.query.until ?? "").trim();
const till = (req.query.till ?? "").trim();
if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
const bounds = [ const bounds = [
since ? gte(ledgerEvents.occurredAt, since) : undefined, since ? gte(ledgerEvents.occurredAt, since) : undefined,
until ? lte(ledgerEvents.occurredAt, until) : undefined, until ? lte(ledgerEvents.occurredAt, until) : undefined,
till ? sql`${tillExpr} = ${till}` : undefined,
types ? inArray(ledgerEvents.type, types) : undefined,
].filter(Boolean); ].filter(Boolean);
const rows = db const rows = db
.select() .select()
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { jobsBehind } from "@parking/shared";
import { buildServer } from "../server.js";
import { login, seedUser } from "../test-helpers.js";
// Roles are data composed from the permission grid (venue-modules.md §Permissions
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
// JOBS it was built from so a grown job can be surfaced and re-applied.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function admin(): Promise<Auth> {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
async function roleChanges(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
);
}
describe("role edits are signed and jobs are remembered", () => {
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
const a = await admin();
const created = await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(created.statusCode).toBe(201);
const role = created.json();
expect(role.jobs).toEqual(["wash-operator"]);
let evs = await roleChanges(a);
expect(evs).toHaveLength(1);
expect(evs[0]!.payload).toMatchObject({
setting: `role.${role.id}`, prev: null, operator: "boss",
value: { name: "Lavazh", jobs: ["wash-operator"] },
});
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
// Same content again → nothing new on the chain.
const same = await app.inject({
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
});
expect(same.statusCode).toBe(200);
expect(await roleChanges(a)).toHaveLength(1);
// A real change: prev is the old shape, value the new.
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
expect(renamed.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(2);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
expect(gone.statusCode).toBe(200);
evs = await roleChanges(a);
expect(evs).toHaveLength(3);
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
});
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
const a = await admin();
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
expect(bad.statusCode).toBe(400);
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
// — exactly what an older release's chip would have left once the job grew.
const r = (await app.inject({
method: "POST", url: "/api/roles", headers: hdrs(a),
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
})).json();
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
const has = new Set<string>(view.permissions);
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
// Re-apply = the union; then nothing is behind.
const fixed = (await app.inject({
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
})).json();
const has2 = new Set<string>(fixed.permissions);
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
});
});
+74 -3
View File
@@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db"; import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared"; import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js"; import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
import type { EventLog } from "../event-log.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
// Role management (admin). Roles are DATA: an admin composes a role from the // Role management (admin). Roles are DATA: an admin composes a role from the
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
// that grants admin-equivalent powers, and escalate. So a non-admin caller may // that grants admin-equivalent powers, and escalate. So a non-admin caller may
// only put permissions they ALREADY hold onto a role. An admin (full set) is // only put permissions they ALREADY hold onto a role. An admin (full set) is
// unrestricted, which is the intended behaviour. // unrestricted, which is the intended behaviour.
//
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
// privilege change, and under this threat model the only setting an admin could alter
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
interface RoleBody { interface RoleBody {
name: string; name: string;
permissions: string[]; permissions: string[];
jobs?: string[];
} }
interface UpdateBody { interface UpdateBody {
name?: string; name?: string;
permissions?: string[]; permissions?: string[];
jobs?: string[];
}
/** What a signed role change records (before/after). */
interface RoleShape {
name: string;
permissions: Permission[];
jobs: string[];
} }
const VALID = new Set<string>(PERMISSIONS); const VALID = new Set<string>(PERMISSIONS);
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
return { ok: true, perms: [...out] }; return { ok: true, perms: [...out] };
} }
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> { /** Validate + dedupe a requested job list against the registry's job presets. */
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
if (input == null) return { ok: true, jobs: [] };
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
const out = new Set<string>();
for (const j of input) {
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
out.add(j);
}
return { ok: true, jobs: [...out] };
}
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
const readGuard = requirePermission("role:read"); const readGuard = requirePermission("role:read");
const createGuard = requirePermission("role:create"); const createGuard = requirePermission("role:create");
const updateGuard = requirePermission("role:update"); const updateGuard = requirePermission("role:update");
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
name: role.name, name: role.name,
builtin: role.builtin === 1, builtin: role.builtin === 1,
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms, permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
jobs: jobsOf(roleId),
userCount, userCount,
}; };
} }
function jobsOf(roleId: string): string[] {
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
}
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
function shapeOf(roleId: string): RoleShape | null {
const v = roleView(roleId);
if (!v) return null;
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
}
/** Replace a role's remembered jobs. */
function setJobs(roleId: string, jobs: string[]): void {
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
}
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
if (JSON.stringify(prev) === JSON.stringify(value)) return;
await eventLog?.append({
type: "config_change",
source: "manual",
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
});
}
/** Replace a role's permission rows with `perms` (in a single pass). */ /** Replace a role's permission rows with `perms` (in a single pass). */
function setPermissions(roleId: string, perms: Permission[]): void { function setPermissions(roleId: string, perms: Permission[]): void {
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run(); db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
const cleaned = cleanPermissions(req.body?.permissions ?? []); const cleaned = cleanPermissions(req.body?.permissions ?? []);
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad }); if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
const jobs = cleanJobs(req.body?.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
const over = escalates(req.user.roleId, cleaned.perms); const over = escalates(req.user.roleId, cleaned.perms);
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
const id = randomUUID(); const id = randomUUID();
db.insert(roles).values({ id, name, builtin: 0 }).run(); db.insert(roles).values({ id, name, builtin: 0 }).run();
setPermissions(id, cleaned.perms); setPermissions(id, cleaned.perms);
setJobs(id, jobs.jobs);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, null, shapeOf(id));
return reply.code(201).send(roleView(id)); return reply.code(201).send(roleView(id));
}); });
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (role.builtin === 1) { if (role.builtin === 1) {
return reply.code(409).send({ error: "the built-in admin role cannot be edited" }); return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
} }
const prev = shapeOf(id);
if (req.body?.name != null) { if (req.body?.name != null) {
const name = req.body.name.trim(); const name = req.body.name.trim();
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` }); if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
setPermissions(id, cleaned.perms); setPermissions(id, cleaned.perms);
} }
if (req.body?.jobs != null) {
const jobs = cleanJobs(req.body.jobs);
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
setJobs(id, jobs.jobs);
}
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, prev, shapeOf(id));
return roleView(id); return roleView(id);
}, },
); );
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
if (holders > 0) { if (holders > 0) {
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` }); return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
} }
const prev = shapeOf(id);
softDelete(db, "role", id, req.user.sub); softDelete(db, "role", id, req.user.sub);
bumpPermsCache(); bumpPermsCache();
await signRoleChange(req, id, prev, null);
return { ok: true }; return { ok: true };
}, },
); );
+1 -1
View File
@@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// RBAC administration: compose roles (role:*) + manage users (user:*). The // RBAC administration: compose roles (role:*) + manage users (user:*). The
// built-in admin role is protected; the last admin can't be removed. See auth.ts. // built-in admin role is protected; the last admin can't be removed. See auth.ts.
await userRoutes(app, db); await userRoutes(app, db);
await roleRoutes(app, db);
// Vision (ANPR) client — built early so the device monitor can include the vision // Vision (ANPR) client — built early so the device monitor can include the vision
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a // service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
@@ -144,6 +143,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// no lane — a parking lot is one pool with a flexible set of entry/exit points. // no lane — a parking lot is one pool with a flexible set of entry/exit points.
// See wiki/concepts/first-run-setup.md, entry-exit-points.md. // See wiki/concepts/first-run-setup.md, entry-exit-points.md.
await setupRoutes(app, db, visionClient, eventLog); await setupRoutes(app, db, visionClient, eventLog);
await roleRoutes(app, db, eventLog);
// Inbound device pushes (e.g. Dingtian Input Link URL → button events), // Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from // guarded by source-IP allowlist + a shared-secret path token, both read from
+111 -48
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db"; import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices"; import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared"; import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -71,12 +71,19 @@ export interface ShiftSummary {
readonly subscriptionSalesMinor: number; readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number; readonly subscriptionWindowMinor: number;
readonly discountTotalMinor: number; readonly discountTotalMinor: number;
readonly chargesByModuleMinor: ChargesByModule;
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
readonly cashAddedMinor: number; readonly cashAddedMinor: number;
readonly cashRemovedMinor: number; readonly cashRemovedMinor: number;
readonly expectedDrawerMinor: number; readonly expectedDrawerMinor: number;
} }
/** Module money folded into this till's payments as `chargeLines`, by owning module —
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
* that actually charged in the window appear. Cash+card already contain it; it is
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
export type ChargesByModule = Partial<Record<ModuleId, number>>;
export interface ShiftReport { export interface ShiftReport {
readonly till: TillId; readonly till: TillId;
readonly operator: string; readonly operator: string;
@@ -98,6 +105,8 @@ export interface ShiftReport {
/** Merchant-validation DISCOUNT total given away in the window (leakage — the /** Merchant-validation DISCOUNT total given away in the window (leakage — the
* cash/card figures above are already NET of it). See validation-discounts.md. */ * cash/card figures above are already NET of it). See validation-discounts.md. */
readonly discountTotalMinor: number; readonly discountTotalMinor: number;
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
readonly chargesByModuleMinor: ChargesByModule;
// --- Drawer (physical cash till; carries across shifts) --- // --- Drawer (physical cash till; carries across shifts) ---
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */ /** Cash in the drawer at shift start = prior shift's expected closing drawer. */
readonly openingFloatMinor: number; readonly openingFloatMinor: number;
@@ -142,6 +151,15 @@ export class InvalidCashMovementError extends Error {
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */ /** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" }; const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
* desk sells washes) and the label a module's charge gets when it rides another
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
* the booth printer when it has none of its own (orderForRole); the booth never falls
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
export class ShiftService { export class ShiftService {
readonly #db: Db; readonly #db: Db;
@@ -248,6 +266,7 @@ export class ShiftService {
subscriptionSalesMinor?: number; subscriptionSalesMinor?: number;
subscriptionWindowMinor?: number; subscriptionWindowMinor?: number;
discountTotalMinor?: number; discountTotalMinor?: number;
chargesByModuleMinor?: ChargesByModule;
openingFloatMinor?: number; openingFloatMinor?: number;
cashAddedMinor?: number; cashAddedMinor?: number;
cashRemovedMinor?: number; cashRemovedMinor?: number;
@@ -283,6 +302,8 @@ export class ShiftService {
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0), (pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0. // Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
discountTotalMinor: pl.discountTotalMinor ?? 0, discountTotalMinor: pl.discountTotalMinor ?? 0,
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
openingFloatMinor: pl.openingFloatMinor ?? 0, openingFloatMinor: pl.openingFloatMinor ?? 0,
cashAddedMinor: pl.cashAddedMinor ?? 0, cashAddedMinor: pl.cashAddedMinor ?? 0,
cashRemovedMinor: pl.cashRemovedMinor ?? 0, cashRemovedMinor: pl.cashRemovedMinor ?? 0,
@@ -558,8 +579,8 @@ export class ShiftService {
.from(ledgerEvents) .from(ledgerEvents)
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the // Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
// parking payment's amount already, as chargeLines). Both fold into the cash/card // parking payment's amount already, as chargeLines). Both fold into the cash/card
// tender totals so the expected drawer is right; a separate wash bucket on the // tender totals so the expected drawer is right; the booth-paid wash is then
// Z-report is a follow-up (venue-modules.md). // broken OUT of the ticket bucket into chargesByModuleMinor (see below).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"])) .where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all() .all()
.filter( .filter(
@@ -578,11 +599,16 @@ export class ShiftService {
// Merchant-validation leakage: Σ discountMinor across the window's payments. The // Merchant-validation leakage: Σ discountMinor across the window's payments. The
// tender totals are already NET; this is the "given away" figure beside them. // tender totals are already NET; this is the "given away" figure beside them.
let discountTotalMinor = 0; let discountTotalMinor = 0;
// Module charges folded into this till's payments (chargeLines on a booth payment),
// summed by owning module. Part of cash/card; NOT ticket money.
const chargesByModuleMinor: ChargesByModule = {};
let chargesTotalMinor = 0;
let currency: string | null = null; let currency: string | null = null;
for (const p of payments) { for (const p of payments) {
const pl = (p.payload ?? {}) as LedgerPayload & { const pl = (p.payload ?? {}) as LedgerPayload & {
subscriptionSale?: boolean; subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean; subscriptionWindowCharge?: boolean;
chargeLines?: ChargeLine[];
}; };
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (pl.tender === "card") cardTotalMinor += amt; if (pl.tender === "card") cardTotalMinor += amt;
@@ -591,10 +617,17 @@ export class ShiftService {
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt; else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
// (else → transient ticket; derived below as total − subscription) // (else → transient ticket; derived below as total − subscription)
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor; if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
for (const l of pl.chargeLines ?? []) {
if (typeof l.amountMinor !== "number" || !l.module) continue;
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
chargesTotalMinor += l.amountMinor;
}
if (pl.currency) currency = pl.currency; if (pl.currency) currency = pl.currency;
} }
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor; const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor; // Ticket = what is left once subscriber money and module charges are taken out:
// ticket + subscriptions + Σcharges = cash + card, always.
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
// --- Drawer figures --- // --- Drawer figures ---
// Opening float was fixed on shift_open (inherited from the chain at start); // Opening float was fixed on shift_open (inherited from the chain at start);
@@ -649,6 +682,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -689,6 +723,7 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
chargesByModuleMinor,
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -713,6 +748,8 @@ export class ShiftService {
subscriptionSalesMinor, subscriptionSalesMinor,
subscriptionWindowMinor, subscriptionWindowMinor,
discountTotalMinor, discountTotalMinor,
// Only when a module charged in the window (older slips/payloads stay identical).
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
openingFloatMinor, openingFloatMinor,
cashAddedMinor, cashAddedMinor,
cashRemovedMinor, cashRemovedMinor,
@@ -729,14 +766,9 @@ export class ShiftService {
return { ...report, printed }; return { ...report, printed };
} }
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event /** Print the Z-report on the till's printer (best-effort; the signed event is the
* is the record — a failed print doesn't undo the close). */ * record — a failed print doesn't undo the close). */
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> { async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
return false;
}
const cur = r.currency ?? ""; const cur = r.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not // Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
@@ -754,11 +786,23 @@ export class ShiftService {
`Kartë: ${money(r.cardTotalMinor)} ${cur}`, `Kartë: ${money(r.cardTotalMinor)} ${cur}`,
"", "",
"-- Arkëtime sipas burimit --", "-- Arkëtime sipas burimit --",
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`, // The booth prints its three classic lines (byte-identical to before tills); a
// Abonime is the subscription TOTAL; only the out-of-window part is broken out. // module's till prints its own takings under its own name — it sells no tickets
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.) // and no subscriptions.
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`, ...(r.till === BOOTH_TILL
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`, ? [
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
]
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
// only when any was taken, so the operator sees parking and wash money apart.
...Object.entries(r.chargesByModuleMinor)
.filter(([, v]) => (v ?? 0) > 0)
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
// Merchant-validation leakage — printed only when the shift actually gave any // Merchant-validation leakage — printed only when the shift actually gave any
// (older slips stay byte-identical). The takings above are already NET of it. // (older slips stay byte-identical). The takings above are already NET of it.
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []), ...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
@@ -770,13 +814,7 @@ export class ShiftService {
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`, `Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`, `Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
]; ];
try { return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
await printer.printReport({ title: "RAPORT TURNI", lines });
return true;
} catch (err) {
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
return false;
}
} }
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort — /** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
@@ -792,11 +830,6 @@ export class ShiftService {
at: string; at: string;
till: TillId; till: TillId;
}): Promise<boolean> { }): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? ""; const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2); const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE"; const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
@@ -810,27 +843,57 @@ export class ShiftService {
"", "",
`Regjistroi: ${v.operator}`, `Regjistroi: ${v.operator}`,
]; ];
try { return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
} }
/** First enabled booth-receipt printer, or any enabled printer. */ /** Print a till's slip on its printer with failover (wash desk → booth printer;
async #boothPrinter(): Promise<PrinterDevice | null> { * see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all(); * record — every failure is logged and reported as "not printed", never thrown.
const enabled = rows.filter((r) => r.enabled); * Legacy fallback: a site whose only printer carries no booth role (one unit,
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0]; * configured as the entry dispenser) still prints its slips on it, as before. */
if (!booth) return null; async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
const driver = registry.get(booth.driverId); const printers = this.#loadPrinters();
if (!driver) return null; const want = TILL_PRINTER_ROLE[till];
try { let ordered = orderForRole(printers, want);
return driver.create(booth.config as never) as PrinterDevice; if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
} catch { if (ordered.length === 0) {
return null; this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
return false;
} }
const attempts: string[] = [];
for (const p of ordered) {
try {
await job(p.device);
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
return true;
} catch (err) {
attempts.push(`${p.id} (${(err as Error).message})`);
}
}
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
return false;
}
/** Every enabled printer as a live instance (role + rank from its saved config). */
#loadPrinters(): PrinterInstance[] {
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
const out: PrinterInstance[] = [];
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
try {
out.push({
id: row.id,
role: printerRoleOf(cfg),
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
device: driver.create(cfg as never) as PrinterDevice,
});
} catch {
// skip a printer whose config won't build
}
}
return out;
} }
} }
+44 -8
View File
@@ -167,22 +167,42 @@ async function recognizePlate(
): Promise<void> { ): Promise<void> {
try { try {
const result = await vision.analyze(shot.bytes, shot.contentType); const result = await vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record if (!result) return;
const plate = result.plate.text.trim().toUpperCase(); // Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
if (!plate) return; // downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
// vehicle out of whatever copy survives and blur the plate inside it.
const frame = await frameSize(shot.bytes);
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
b && frame
? {
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
}
: null;
// The vehicle's body type (advisory; the wash desk's category suggestion — see
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
// else a row of its own: a car with an unreadable plate is still a car of some class.
const vehicleBox = norm(result.vehicle?.bbox);
const vehicle = result.vehicle
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
: {};
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
const plateBox = plate ? norm(result.plate?.bbox) : null;
if (!plate && !result.vehicle) return; // nothing trustworthy to record
db.insert(deviceEventsTable) db.insert(deviceEventsTable)
.values({ .values({
id: randomUUID(), id: randomUUID(),
deviceId, deviceId,
category: "camera", category: "camera",
kind: "read", kind: "read",
// `identity` ties the plate to the session; `snapshotId` to the evidence image. // `identity` ties the read to the session; `snapshotId` to the evidence image.
detail: { detail: {
identity, identity,
direction, direction,
plate, ...(plate
confidence: result.plate.confidence, ? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) }
region: result.plate.region ?? null, : {}),
...vehicle,
modelVersion: result.modelVersion, modelVersion: result.modelVersion,
snapshotId, snapshotId,
source: "entry-exit-snapshot", source: "entry-exit-snapshot",
@@ -190,7 +210,9 @@ async function recognizePlate(
occurredAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
}) })
.run(); .run();
logger.info(`anpr plate '${plate}' (${result.plate.confidence.toFixed(3)}) for ${identity}`); if (result.vehicle) logger.info(`vision vehicle '${result.vehicle.bodyType}' (${result.vehicle.confidence.toFixed(3)}) for ${identity}`);
if (!plate) return;
logger.info(`anpr plate '${plate}' (${result.plate!.confidence.toFixed(3)}) for ${identity}`);
// The session's entry/exit event already shipped without this (async) plate — tell the // The session's entry/exit event already shipped without this (async) plate — tell the
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched. // booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction }); deviceEvents.emitPlateRecognized({ identity, plate, direction });
@@ -206,6 +228,20 @@ async function recognizePlate(
} }
} }
function clamp01(v: number): number {
return Math.max(0, Math.min(1, v));
}
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
try {
const m = await sharp(bytes, { failOn: "none" }).metadata();
return m.width && m.height ? { w: m.width, h: m.height } : null;
} catch {
return null;
}
}
/** How far back a recognized entry plate is compared against other OPEN sessions' /** How far back a recognized entry plate is compared against other OPEN sessions'
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car * entry plates. Short on purpose: the duplicate-ticket scenario is the same car
* re-pressing within minutes; a long window would flag legit re-visits. */ * re-pressing within minutes; a long window would flag legit re-visits. */
+22 -3
View File
@@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify";
// transport + contract adapter only. // transport + contract adapter only.
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */ /** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
import { isVehicleClass, type VehicleClass } from "@parking/shared";
export interface PlateBBox { export interface PlateBBox {
readonly x1: number; readonly x1: number;
readonly y1: number; readonly y1: number;
@@ -40,12 +42,21 @@ export interface VisionPlate {
readonly region?: string | null; readonly region?: string | null;
} }
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for /** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
* Job 2 (vehicle verification) — not yet produced. */ * VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a
* plate-only recognizer sends null. */
export interface VisionVehicle {
readonly bodyType: VehicleClass;
readonly confidence: number;
/** The vehicle's box in frame pixels, when the stage found one. */
readonly bbox?: PlateBBox | null;
}
/** The raw /analyze response shape (the Python contract). */
interface AnalyzeResponse { interface AnalyzeResponse {
readonly plate: VisionPlate | null; readonly plate: VisionPlate | null;
readonly plates: VisionPlate[]; readonly plates: VisionPlate[];
readonly vehicle: unknown | null; readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | null;
readonly low_confidence: boolean; readonly low_confidence: boolean;
readonly model_version: string; readonly model_version: string;
readonly took_ms: number; readonly took_ms: number;
@@ -61,6 +72,8 @@ export interface VisionResult {
/** True when the best plate is below the confidence floor — treat as advisory only /** True when the best plate is below the confidence floor — treat as advisory only
* and fall back to the ticket/manual path. */ * and fall back to the ticket/manual path. */
readonly lowConfidence: boolean; readonly lowConfidence: boolean;
/** The vehicle's body type, when the service ran that stage and named a known class. */
readonly vehicle: VisionVehicle | null;
readonly modelVersion: string; readonly modelVersion: string;
readonly tookMs: number; readonly tookMs: number;
} }
@@ -124,10 +137,16 @@ export class VisionClient {
const best = res.plate ?? null; const best = res.plate ?? null;
const lowConfidence = const lowConfidence =
res.low_confidence || (best != null && best.confidence < this.#minConfidence); res.low_confidence || (best != null && best.confidence < this.#minConfidence);
const v = res.vehicle;
const vehicle: VisionVehicle | null =
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null }
: null;
return { return {
plate: best, plate: best,
plates: Array.isArray(res.plates) ? res.plates : [], plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence, lowConfidence,
vehicle,
modelVersion: res.model_version ?? "unknown", modelVersion: res.model_version ?? "unknown",
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0, tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
}; };
+9
View File
@@ -20,3 +20,12 @@ VISION_OCR_MODEL=cct-xs-v2-global-model
# Confidence floor — a best plate below this is flagged low_confidence so the Node side # Confidence floor — a best plate below this is flagged low_confidence so the Node side
# treats it as advisory and falls back to the ticket path. Keep in sync with the server. # treats it as advisory and falls back to the ticket path. Keep in sync with the server.
VISION_MIN_CONFIDENCE=0.5 VISION_MIN_CONFIDENCE=0.5
# Vehicle stage (phase A): a YOLOX ONNX graph (Apache-2.0) run on the same frame after the
# plate read; fills /analyze `vehicle.body_type` (car/truck/bus/motorcycle) + confidence for
# the Car Wash desk's category suggestion. Unset = off. The Docker image bakes the weights
# at /app/models/yolox_s.onnx; locally: curl the release file into apps/vision/models/.
# https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
# VISION_VEHICLE_MODEL_PATH=models/yolox_s.onnx
# VISION_VEHICLE_INPUT_SIZE=640
# VISION_VEHICLE_MIN_CONFIDENCE=0.4
+10 -2
View File
@@ -14,7 +14,7 @@ ENV UV_LINK_MODE=copy \
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal. # System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \ && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ---- # ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
@@ -26,6 +26,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# ---- project source ---- # ---- project source ----
COPY vision_service/ ./vision_service/ COPY vision_service/ ./vision_service/
COPY README.md ./ COPY README.md ./
# Vehicle stage weights (phase A): YOLOX-S, Apache-2.0, ~36 MB, baked into the image so the
# air-gapped appliance never fetches at runtime and no operator-writable path holds a model
# (vision-service-hardening.md). Best-effort at build: without network the stage stays off.
ARG YOLOX_URL=https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
RUN mkdir -p /app/models \
&& (curl -fsSL -o /app/models/yolox_s.onnx "$YOLOX_URL" \
|| (echo "[build] yolox weights not fetched (no network) — vehicle stage off" && rm -f /app/models/yolox_s.onnx))
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra alpr uv sync --frozen --extra alpr
@@ -49,7 +56,8 @@ RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod. # Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
ENV VISION_RECOGNIZER=stub \ ENV VISION_RECOGNIZER=stub \
VISION_HOST=0.0.0.0 \ VISION_HOST=0.0.0.0 \
VISION_PORT=8089 VISION_PORT=8089 \
VISION_VEHICLE_MODEL_PATH=/app/models/yolox_s.onnx
EXPOSE 8089 EXPOSE 8089
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1 CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
+143
View File
@@ -0,0 +1,143 @@
"""Vehicle stage (phase A) — pure post-processing on synthetic tensors, and the
recognizer composition over the stub with a fake detector. No weights needed."""
from __future__ import annotations
import os
import numpy as np
from fastapi.testclient import TestClient
from vision_service.schemas import BBox, VehicleResult
from vision_service.vehicle import (
COCO_VEHICLE_CLASSES,
Detection,
decode,
letterbox,
nms,
pick_vehicle,
vehicles_from_output,
)
SIZE = 64 # tiny "model" input: grids 8x8 + 4x4 + 2x2 = 84 rows
ROWS = (SIZE // 8) ** 2 + (SIZE // 16) ** 2 + (SIZE // 32) ** 2
def raw_output(hits: list[tuple[int, int, int, float, float, float, float]]) -> np.ndarray:
"""Build a YOLOX-style raw tensor [ROWS, 85] with the given (row, coco_class, _, obj,
cls_score, log_w, log_h) hits; everything else is background."""
raw = np.zeros((ROWS, 85), dtype=np.float32)
raw[:, 2:4] = -10.0 # exp → ~0 size for background rows
for row, cls, _, obj, score, lw, lh in hits:
raw[row, 0:2] = 0.5 # centre of its grid cell
raw[row, 2] = lw
raw[row, 3] = lh
raw[row, 4] = obj
raw[row, 5 + cls] = score
return raw
def test_decode_maps_grid_offsets_and_log_sizes_to_pixels() -> None:
raw = raw_output([(0, 2, 0, 1.0, 1.0, np.log(2.0), np.log(3.0))])
dec = decode(raw, SIZE)
# Row 0 = stride-8 grid cell (0,0): centre (0.5+0)*8 = 4, size exp(log 2)*8 = 16 / 24.
assert dec[0, :4].tolist() == [4.0, 4.0, 16.0, 24.0]
# Last row = stride-32 cell (1,1): centre (0.5+1)*32 = 48.
raw2 = raw_output([(ROWS - 1, 7, 0, 1.0, 1.0, 0.0, 0.0)])
dec2 = decode(raw2, SIZE)
assert dec2[ROWS - 1, :4].tolist() == [48.0, 48.0, 32.0, 32.0]
def test_vehicles_only_above_floor_mapped_to_vocabulary_and_scaled_back() -> None:
raw = raw_output(
[
(0, 2, 0, 0.9, 0.9, np.log(2.0), np.log(2.0)), # car, score .81
(1, 0, 0, 0.99, 0.99, np.log(2.0), np.log(2.0)), # person → ignored
(2, 7, 0, 0.5, 0.5, np.log(2.0), np.log(2.0)), # truck, score .25 → below floor
]
)
found = vehicles_from_output(raw, SIZE, scale=0.5, min_confidence=0.4)
assert [d.body_type for d in found] == ["car"]
assert round(found[0].confidence, 2) == 0.81
# Box 16px wide in the letterboxed input → 32px in the original (scale 0.5).
assert round(found[0].x2 - found[0].x1) == 32
assert set(COCO_VEHICLE_CLASSES.values()) == {"car", "motorcycle", "bus", "truck"}
def test_nms_keeps_the_best_of_overlapping_boxes() -> None:
boxes = np.array([[0, 0, 10, 10], [1, 1, 11, 11], [50, 50, 60, 60]], dtype=np.float32)
scores = np.array([0.5, 0.9, 0.7], dtype=np.float32)
assert sorted(nms(boxes, scores, 0.45)) == [1, 2]
def test_pick_prefers_the_box_holding_the_plate_else_the_largest() -> None:
near = Detection("car", 0.9, 0, 0, 100, 100)
far = Detection("truck", 0.8, 200, 200, 400, 400) # larger
inside = Detection("car", 0.7, 10, 10, 60, 60) # tighter box also holding the plate
assert pick_vehicle([near, far], None) is far
assert pick_vehicle([near, far], BBox(x1=20, y1=20, x2=30, y2=30)) is near
assert pick_vehicle([near, far, inside], BBox(x1=20, y1=20, x2=30, y2=30)) is inside
assert pick_vehicle([near, far], BBox(x1=900, y1=900, x2=910, y2=910)) is far # plate outside every box
assert pick_vehicle([], None) is None
def test_letterbox_keeps_aspect_and_pads_with_114() -> None:
frame = np.zeros((30, 60, 3), dtype=np.uint8)
tensor, scale = letterbox(frame, 64)
assert tensor.shape == (1, 3, 64, 64) and tensor.dtype == np.float32
assert abs(scale - 64 / 60) < 1e-9
assert tensor[0, 0, 63, 63] == 114.0 # padding
assert tensor[0, 0, 0, 0] == 0.0 # image
class FakeDetector:
model_version = "fake-vehicle"
def __init__(self, result: VehicleResult | None) -> None:
self.result = result
self.calls: list[BBox | None] = []
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
self.calls.append(plate)
return self.result
def test_composition_fills_vehicle_over_the_stub_and_survives_a_failing_stage() -> None:
from vision_service.recognizer import StubRecognizer, WithVehicle
from vision_service.settings import Settings
det = FakeDetector(VehicleResult(body_type="truck", confidence=0.77))
rec = WithVehicle(StubRecognizer(Settings()), det)
res = rec.analyze(b"jpeg-bytes")
assert res.plate is None
assert res.vehicle == VehicleResult(body_type="truck", confidence=0.77)
assert res.model_version == "stub-0+fake-vehicle"
assert det.calls == [None]
class Boom:
model_version = "boom"
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
raise RuntimeError("no model")
rec2 = WithVehicle(StubRecognizer(Settings()), Boom())
res2 = rec2.analyze(b"jpeg-bytes")
assert res2.vehicle is None
assert rec2.ready is True
assert "vehicle: RuntimeError: no model" in (rec2.error or "")
def test_app_reports_a_missing_model_file_and_keeps_serving() -> None:
from vision_service.app import app
os.environ["VISION_VEHICLE_MODEL_PATH"] = "/nonexistent/yolox.onnx"
try:
with TestClient(app) as client:
health = client.get("/health").json()
assert health["ready"] is True # the plate stage (stub) is fine
assert "vehicle:" in (health["detail"] or "")
res = client.post("/analyze", content=b"x", headers={"content-type": "application/octet-stream"})
assert res.status_code == 200
assert res.json()["vehicle"] is None
finally:
os.environ.pop("VISION_VEHICLE_MODEL_PATH", None)
+53 -3
View File
@@ -19,6 +19,7 @@ from typing import Protocol
from .schemas import AnalyzeResponse, BBox, PlateResult from .schemas import AnalyzeResponse, BBox, PlateResult
from .settings import Settings from .settings import Settings
from .vehicle import VehicleDetector, YoloxVehicleDetector
class Recognizer(Protocol): class Recognizer(Protocol):
@@ -165,10 +166,59 @@ class FastAlprRecognizer:
) )
class WithVehicle:
"""Composition: any plate recognizer + the vehicle stage. Runs the plate stage first
(its box picks WHICH vehicle), then fills `vehicle`. A failing vehicle stage is
logged into `error` and yields null — it must never cost the plate read."""
def __init__(self, inner: Recognizer, detector: VehicleDetector) -> None:
self._inner = inner
self._detector = detector
self.vehicle_error: str | None = None
@property
def model_version(self) -> str:
return f"{self._inner.model_version}+{self._detector.model_version}"
@property
def ready(self) -> bool:
return bool(self._inner.ready)
@property
def error(self) -> str | None:
inner = getattr(self._inner, "error", None)
det = getattr(self._detector, "error", None) or self.vehicle_error
parts = [p for p in (inner, f"vehicle: {det}" if det else None) if p]
return "; ".join(parts) if parts else None
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
started = time.perf_counter()
res = self._inner.analyze(image_bytes)
try:
vehicle = self._detector.detect(image_bytes, res.plate.bbox if res.plate else None)
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
self.vehicle_error = f"{type(exc).__name__}: {exc}"
vehicle = None
took_ms = (time.perf_counter() - started) * 1000.0
return res.model_copy(
update={"vehicle": vehicle, "model_version": self.model_version, "took_ms": took_ms}
)
def build_recognizer(settings: Settings) -> Recognizer: def build_recognizer(settings: Settings) -> Recognizer:
"""Factory: pick the recognizer from settings. Falls back to the stub if the real """Factory: pick the recognizer from settings. Falls back to the stub if the real
one can't load, so the service always comes up (with ready=False surfaced).""" one can't load, so the service always comes up (with ready=False surfaced). The
vehicle stage wraps whichever recognizer runs when a model path is configured."""
rec: Recognizer
if settings.recognizer == "fast_alpr": if settings.recognizer == "fast_alpr":
rec = FastAlprRecognizer(settings) rec = FastAlprRecognizer(settings)
return rec else:
return StubRecognizer(settings) rec = StubRecognizer(settings)
if settings.vehicle_model_path:
detector = YoloxVehicleDetector(
settings.vehicle_model_path,
input_size=settings.vehicle_input_size,
min_confidence=settings.vehicle_min_confidence,
)
return WithVehicle(rec, detector)
return rec
+11 -1
View File
@@ -31,10 +31,20 @@ class PlateResult(BaseModel):
class VehicleResult(BaseModel): class VehicleResult(BaseModel):
"""Job 2 — vehicle attributes / fingerprint (anti-spoofing). Not yet produced.""" """Job 2 — vehicle attributes. `body_type` is ADVISORY: the Node server records it
beside the plate and the Car Wash desk pre-selects the site category it maps to; the
operator decides, a disagreement is flagged, nothing is ever gated on it. Values come
from the shared vocabulary (car, sedan, hatchback, suv, minivan, pickup, van, truck,
bus, motorcycle) — anything else is ignored by Node. Phase A (a COCO detector) emits
car/truck/bus/motorcycle; the finer classes need the body-type classifier. Not yet
produced by any bundled recognizer."""
colour: str | None = None colour: str | None = None
body_type: str | None = None body_type: str | None = None
# Confidence of `body_type` (0–1). Node compares it to the site's threshold.
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
bbox: BBox | None = None
make: str | None = None make: str | None = None
model: str | None = None model: str | None = None
+10
View File
@@ -32,6 +32,16 @@ class Settings(BaseSettings):
# Node side can fall back to the ticket path rather than trust it. # Node side can fall back to the ticket path rather than trust it.
min_confidence: float = 0.5 min_confidence: float = 0.5
# Vehicle stage (phase A — venue-modules.md §Vehicle category from vision): a YOLOX
# ONNX graph (Apache-2.0) run beside the plate recognizer. Unset = stage off (the
# response's `vehicle` stays null). Bake the file into the image (models/), never a
# path an operator can write (vision-service-hardening.md).
vehicle_model_path: str | None = None
vehicle_input_size: int = 640
# Detection score floor for a vehicle box to count at all (the Node side applies the
# site's own, stricter threshold before it FLAGS anything).
vehicle_min_confidence: float = 0.4
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() return Settings()
+251
View File
@@ -0,0 +1,251 @@
"""Vehicle stage: a COCO object detector beside the plate recognizer (Job 2, phase A).
Answers "what KIND of vehicle is in this entry frame?" for the Car Wash desk's category
suggestion (wiki/decisions/venue-modules.md §Vehicle category from vision). ADVISORY by
design: the Node server records it next to the plate, the desk pre-selects the site
category it maps to, the operator decides, a confident downgrade is flagged. Nothing is
ever gated on it, so a wrong or missing detection costs nothing but a suggestion.
Model: YOLOX (Megvii, Apache-2.0) as an ONNX graph on the ONNX Runtime the plate stage
already uses — the licence rule that keeps Ultralytics (AGPL) out. COCO's vehicle classes
are car / motorcycle / bus / truck: enough to tell a van or a truck from a car, NOT enough
for SUV vs sedan — that is phase B (a body-type classifier on the pilot's own frames).
The detector's vehicle box is also the crop phase B will classify.
Pure numpy/cv2 pre/post-processing, no torch: letterbox to the model's square input
(pad 114, no normalisation — YOLOX's exported graphs take raw 0–255 BGR), decode the
stride grids, class-agnostic NMS, map COCO ids to the shared vocabulary, pick ONE
vehicle: the one whose box holds the plate (when a plate was read), else the largest.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from .schemas import BBox, VehicleResult
# COCO-80 class index → the shared VEHICLE_CLASSES vocabulary (packages/shared).
COCO_VEHICLE_CLASSES: dict[int, str] = {2: "car", 3: "motorcycle", 5: "bus", 7: "truck"}
# YOLOX feature strides; grids are input/stride per level (8400 anchors at 640).
_STRIDES = (8, 16, 32)
@dataclass(frozen=True)
class Detection:
body_type: str
confidence: float
x1: float
y1: float
x2: float
y2: float
@property
def area(self) -> float:
return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1)
def contains(self, x: float, y: float) -> bool:
return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
class VehicleDetector(Protocol):
"""What the recognizer composition needs: frame bytes (+ the plate box) → a class."""
@property
def model_version(self) -> str: ...
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None: ...
# ----------------------------------------------------------------------------------
# Pre/post-processing (pure functions — unit-tested on synthetic tensors)
# ----------------------------------------------------------------------------------
def letterbox(frame: Any, size: int) -> tuple[Any, float]:
"""Resize keeping aspect, pad bottom/right with 114 to size×size. Returns the CHW
float32 tensor (batch dim added) and the scale to map boxes back."""
import cv2
import numpy as np
h, w = frame.shape[:2]
r = min(size / h, size / w)
nh, nw = int(round(h * r)), int(round(w * r))
resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)
padded = np.full((size, size, 3), 114, dtype=np.uint8)
padded[:nh, :nw] = resized
tensor = padded.transpose(2, 0, 1)[None].astype(np.float32)
return np.ascontiguousarray(tensor), r
def decode(raw: Any, size: int) -> Any:
"""YOLOX raw output [N, 5+classes] (batch squeezed) → same shape with xywh decoded
into pixel units of the letterboxed input. Rows are ordered stride 8, 16, 32."""
import numpy as np
out = raw.astype(np.float32).copy()
grids = []
strides = []
for s in _STRIDES:
n = size // s
ys, xs = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
grids.append(np.stack((xs, ys), axis=-1).reshape(-1, 2))
strides.append(np.full((n * n, 1), s, dtype=np.float32))
grid = np.concatenate(grids, axis=0).astype(np.float32)
stride = np.concatenate(strides, axis=0)
if out.shape[0] != grid.shape[0]:
raise ValueError(f"unexpected output rows {out.shape[0]} for input {size} (want {grid.shape[0]})")
out[:, :2] = (out[:, :2] + grid) * stride
out[:, 2:4] = np.exp(out[:, 2:4]) * stride
return out
def nms(boxes: Any, scores: Any, iou_threshold: float) -> list[int]:
"""Greedy class-agnostic non-max suppression over xyxy boxes; returns kept indices."""
import numpy as np
if len(boxes) == 0:
return []
order = scores.argsort()[::-1]
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
areas = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
keep: list[int] = []
while order.size > 0:
i = int(order[0])
keep.append(i)
if order.size == 1:
break
rest = order[1:]
xx1 = np.maximum(x1[i], x1[rest])
yy1 = np.maximum(y1[i], y1[rest])
xx2 = np.minimum(x2[i], x2[rest])
yy2 = np.minimum(y2[i], y2[rest])
inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None)
iou = inter / (areas[i] + areas[rest] - inter + 1e-9)
order = rest[iou <= iou_threshold]
return keep
def vehicles_from_output(
raw: Any, size: int, scale: float, min_confidence: float, iou_threshold: float = 0.45
) -> list[Detection]:
"""Full post-processing: decode → vehicle classes only → confidence floor → NMS →
boxes in ORIGINAL frame pixels."""
import numpy as np
dec = decode(raw, size)
cls_scores = dec[:, 5:]
cls_idx = cls_scores.argmax(axis=1)
score = dec[:, 4] * cls_scores[np.arange(len(dec)), cls_idx]
wanted = np.isin(cls_idx, list(COCO_VEHICLE_CLASSES)) & (score >= min_confidence)
if not wanted.any():
return []
d = dec[wanted]
s = score[wanted]
c = cls_idx[wanted]
boxes = np.stack(
(d[:, 0] - d[:, 2] / 2, d[:, 1] - d[:, 3] / 2, d[:, 0] + d[:, 2] / 2, d[:, 1] + d[:, 3] / 2), axis=1
)
keep = nms(boxes, s, iou_threshold)
out: list[Detection] = []
for i in keep:
b = boxes[i] / scale
out.append(
Detection(
body_type=COCO_VEHICLE_CLASSES[int(c[i])],
confidence=float(s[i]),
x1=float(b[0]),
y1=float(b[1]),
x2=float(b[2]),
y2=float(b[3]),
)
)
return out
def pick_vehicle(detections: list[Detection], plate: BBox | None) -> Detection | None:
"""ONE vehicle per frame: the box holding the plate's centre (the car that was read —
a lane frame can show the car behind too), else the largest box (nearest the camera)."""
if not detections:
return None
if plate is not None:
cx = (plate.x1 + plate.x2) / 2
cy = (plate.y1 + plate.y2) / 2
holders = [d for d in detections if d.contains(cx, cy)]
if holders:
return min(holders, key=lambda d: d.area) # the tightest box around the plate
return max(detections, key=lambda d: d.area)
# ----------------------------------------------------------------------------------
# The ONNX Runtime detector
# ----------------------------------------------------------------------------------
class YoloxVehicleDetector:
"""YOLOX ONNX on onnxruntime (CPU). Loads once; a load failure is surfaced through
`error` and the stage simply yields no vehicle (never breaks the plate path)."""
def __init__(self, model_path: str, input_size: int = 640, min_confidence: float = 0.4) -> None:
self._path = Path(model_path)
self._size = input_size
self._min_confidence = min_confidence
self._session = None
self._input_name = "images"
self._error: str | None = None
try:
import onnxruntime as ort
opts = ort.SessionOptions()
opts.intra_op_num_threads = 2 # one frame per entry; leave cores to the lane
self._session = ort.InferenceSession(
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
)
self._input_name = self._session.get_inputs()[0].name
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
self._error = f"{type(exc).__name__}: {exc}"
@property
def model_version(self) -> str:
return f"yolox:{self._path.name}@{self._size}"
@property
def ready(self) -> bool:
return self._session is not None
@property
def error(self) -> str | None:
return self._error
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
if self._session is None:
return None
import cv2
import numpy as np
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
return None
tensor, scale = letterbox(frame, self._size)
raw = self._session.run(None, {self._input_name: tensor})[0][0]
found = vehicles_from_output(raw, self._size, scale, self._min_confidence)
best = pick_vehicle(found, plate)
if best is None:
return None
h, w = frame.shape[:2]
box = BBox(
x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2))
)
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
def time_detect(
detector: VehicleDetector, image_bytes: bytes, plate: BBox | None
) -> tuple[VehicleResult | None, float]:
"""detect() with wall time in ms (for logs/benchmarks)."""
started = time.perf_counter()
result = detector.detect(image_bytes, plate)
return result, (time.perf_counter() - started) * 1000.0
+6 -3
View File
@@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js"; import { ActiveSessions } from "./ActiveSessions.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js"; import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { EventDetailModal, EventRow } from "./ui/event-detail.js"; import { EventDetailModal, EventRow } from "./ui/event-detail.js";
import { tillOfEvent } from "@parking/shared";
// The live operator booth view — the real-time heart of the console. Occupancy // The live operator booth view — the real-time heart of the console. Occupancy
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and // gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
@@ -246,7 +247,7 @@ export function BoothScreen() {
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy }); const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: [...qk.events, shiftStart ?? "none"], queryKey: [...qk.events, shiftStart ?? "none"],
queryFn: () => fetchEvents(100, shiftStart ?? undefined), queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"),
enabled: shiftOpen, enabled: shiftOpen,
}); });
@@ -275,13 +276,15 @@ export function BoothScreen() {
// Merge: live events first (newest), then the queried history, de-duped by id — // Merge: live events first (newest), then the queried history, de-duped by id —
// then clip to the current shift window (the live store spans shifts; the feed // then clip to the current shift window (the live store spans shifts; the feed
// must not show events from before this shift's start). No shift → no feed. // must not show events from before this shift's start) and to the BOOTH till (the
// socket also pushes wash-desk events to anyone with carwash:read; they are the wash
// shift's activity, not this one's — tillOfEvent). No shift → no feed.
const seen = new Set(liveFeed.map((e) => e.id)); const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id)); const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200); const merged = [...liveFeed, ...history].slice(0, 200);
const scoped = const scoped =
shiftOpen && shiftStart shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart) ? merged.filter((e) => e.occurredAt >= shiftStart && tillOfEvent(e.type, e.payload) === "booth")
: []; : [];
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`) // Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
+2 -2
View File
@@ -177,8 +177,8 @@ function StatePanel({ till }: { till: TillId }) {
function TodayPanel({ till }: { till: TillId }) { function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "today"], queryKey: ["drawer", "today", till],
queryFn: () => fetchEvents(1000, startOfToday()), queryFn: () => fetchEvents(1000, startOfToday(), undefined, till),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
+49 -8
View File
@@ -13,7 +13,7 @@ import {
type SessionUser, type SessionUser,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared"; import { MODULES, jobsBehind, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
// Role management (admin). Compose a role from the permission grid (a checkbox // Role management (admin). Compose a role from the permission grid (a checkbox
// matrix of resource × action) and name it; users are then assigned a role. The // matrix of resource × action) and name it; users are then assigned a role. The
@@ -27,6 +27,11 @@ import { MODULES, tillsFor, type JobPreset, type ModuleId, type TillId } from "@
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks): // fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read // "mixes desks" (may open more than one till) and "partial job" (holds a module's read
// permission but not the rest of its job — a desk that can look but not act). // permission but not the rest of its job — a desk that can look but not act).
//
// A role REMEMBERS the jobs it follows (chips on at save, or bundles fully present). When
// a later release grows a job, the role shows as "behind" it — in the list (with a
// one-click re-apply) and in the editor — instead of silently falling short the way the
// wash operator's price list did (2026-09-06). Every save is signed on the ledger.
/** Group "resource:action" permissions by resource for the grid rows. */ /** Group "resource:action" permissions by resource for the grid rows. */
function groupByResource(perms: Permission[]): Record<string, Permission[]> { function groupByResource(perms: Permission[]): Record<string, Permission[]> {
@@ -111,8 +116,17 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
<span className="text-[0.6875rem] text-term-muted"> <span className="text-[0.6875rem] text-term-muted">
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })} {t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
</span> </span>
{behindOf(r).map((b) => (
<span key={b.job} className="rounded-term border border-term-amber/60 px-1.5 py-0.5 text-[0.625rem] text-term-amber" title={b.missing.join(", ")}>
{t("roles.behind", { job: t(`jobs.${b.job}`) })}
</span>
))}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{canUpdate && !r.builtin && behindOf(r).length > 0 && (
<button type="button" className="btn btn-primary btn-sm" title={behindOf(r).flatMap((b) => b.missing).join(", ")}
onClick={() => reapply(r, invalidate, onError)}>{t("roles.reapply")}</button>
)}
{canUpdate && !r.builtin && ( {canUpdate && !r.builtin && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button> <button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
)} )}
@@ -133,15 +147,30 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
try { await deleteRole(id); ok(); } catch (e) { onError(e); } try { await deleteRole(id); ok(); } catch (e) { onError(e); }
} }
/** The jobs a role follows that have grown past it (this release's bundles). */
function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] {
const has = new Set(r.permissions);
return jobsBehind(r.jobs ?? [], (p) => has.has(p));
}
/** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is
* signed like any other role edit. */
async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) {
const missing = behindOf(r).flatMap((b) => b.missing);
try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); }
}
/** The jobs the composer offers: every effective module's, in registry order. */ /** The jobs the composer offers: every effective module's, in registry order. */
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] { function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job }))); return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
} }
/** Composer lints — warnings about what the admin just composed. */ /** Composer lints — warnings about what the admin just composed. */
function lintRole(perms: Set<Permission>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] { function lintRole(perms: Set<Permission>, jobs: Set<string>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
const has = (p: Permission) => perms.has(p); const has = (p: Permission) => perms.has(p);
const out: { key: string; vars?: Record<string, string> }[] = []; const out: { key: string; vars?: Record<string, string> }[] = [];
// Behind a job it follows: the bundle grew (a newer release) past what the role holds.
for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } });
// Mixes desks: may OPEN more than one till. // Mixes desks: may OPEN more than one till.
const workable: TillId[] = tillsFor(effective, has, "shift"); const workable: TillId[] = tillsFor(effective, has, "shift");
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } }); if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
@@ -165,11 +194,14 @@ function RoleEditor({
grouped: Record<string, Permission[]>; grouped: Record<string, Permission[]>;
effective: readonly ModuleId[]; effective: readonly ModuleId[];
onCancel: () => void; onCancel: () => void;
onSubmit: (v: { name: string; permissions: Permission[] }) => void; onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(role?.name ?? ""); const [name, setName] = useState(role?.name ?? "");
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? [])); const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
// The jobs this role follows: what was remembered, plus (at save) any bundle that is
// fully present — so a role composed before jobs were remembered picks them up.
const [jobIds, setJobIds] = useState<Set<string>>(new Set(role?.jobs ?? []));
const toggle = (p: Permission) => const toggle = (p: Permission) =>
setPerms((prev) => { setPerms((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -177,15 +209,24 @@ function RoleEditor({
return next; return next;
}); });
const jobs = useMemo(() => jobsFor(effective), [effective]); const jobs = useMemo(() => jobsFor(effective), [effective]);
const jobOn = (job: JobPreset) => job.permissions.every((p) => perms.has(p)); const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
const toggleJob = (job: JobPreset) => const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job);
const toggleJob = (job: JobPreset) => {
const on = jobOn(job);
setJobIds((prev) => {
const next = new Set(prev);
on ? next.delete(job.id) : next.add(job.id);
return next;
});
setPerms((prev) => { setPerms((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (job.permissions.every((p) => prev.has(p))) for (const p of job.permissions) next.delete(p); if (on) for (const p of job.permissions) next.delete(p);
else for (const p of job.permissions) next.add(p); else for (const p of job.permissions) next.add(p);
return next; return next;
}); });
const lints = useMemo(() => lintRole(perms, effective), [perms, effective]); };
const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]);
const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id);
const valid = name.trim().length > 0; const valid = name.trim().length > 0;
@@ -244,7 +285,7 @@ function RoleEditor({
<div className="mt-3 flex justify-end gap-2"> <div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button> <button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button> <button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms], jobs: followed() })}>{t("common.save")}</button>
</div> </div>
</div> </div>
); );
+10 -1
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js"; import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
@@ -161,6 +161,15 @@ function CloseShiftConfirm({
<> <>
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} /> <ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} /> <ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* Module money that rode the ticket (a booth-paid wash) — only when any did. */}
{Object.entries(x.chargesByModuleMinor ?? {})
.filter(([, v]) => (v ?? 0) > 0)
.map(([m, v]) => (
<Fragment key={m}>
<ConfirmFigure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={fmt(v ?? 0)} />
<span />
</Fragment>
))}
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit' {/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */} part is broken out below it; subscription SALES is not (it's the remainder). */}
<span /> <span />
+27 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
@@ -91,6 +91,7 @@ function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workabl
subscriptionTotalMinor: x.subscriptionTotalMinor, subscriptionTotalMinor: x.subscriptionTotalMinor,
subscriptionSalesMinor: x.subscriptionSalesMinor, subscriptionSalesMinor: x.subscriptionSalesMinor,
subscriptionWindowMinor: x.subscriptionWindowMinor, subscriptionWindowMinor: x.subscriptionWindowMinor,
chargesByModuleMinor: x.chargesByModuleMinor,
openingFloatMinor: x.openingFloatMinor, openingFloatMinor: x.openingFloatMinor,
cashAddedMinor: x.cashAddedMinor, cashAddedMinor: x.cashAddedMinor,
cashRemovedMinor: x.cashRemovedMinor, cashRemovedMinor: x.cashRemovedMinor,
@@ -332,6 +333,24 @@ function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s:
); );
} }
/** One figure per module whose money rode this till's tickets (a booth-paid wash) —
* nothing when none did, so booth-only sites see the report they always saw. `spacer`
* keeps a 2-column grid's pairs aligned. */
function ChargeFigures({ charges, cur, spacer }: { charges?: Partial<Record<string, number>>; cur: string | null; spacer?: boolean }) {
const { t } = useTranslation();
const rows = Object.entries(charges ?? {}).filter(([, v]) => (v ?? 0) > 0);
return (
<>
{rows.map(([m, v]) => (
<Fragment key={m}>
<Figure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={money(v ?? 0, cur)} />
{spacer && <span />}
</Fragment>
))}
</>
);
}
function ShiftActivityLog({ function ShiftActivityLog({
shift, shift,
isCurrent, isCurrent,
@@ -356,9 +375,10 @@ function ShiftActivityLog({
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null); const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded. // The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
// Per till: the booth's log has no wash-desk activity in it, and vice versa.
const q = useQuery({ const q = useQuery({
queryKey: ["shift-events", shift.id, shift.endedAt], queryKey: ["shift-events", shift.id, shift.endedAt, shift.till],
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt), queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till),
refetchInterval: isCurrent ? 5000 : false, refetchInterval: isCurrent ? 5000 : false,
}); });
const events = q.data?.events ?? []; const events = q.data?.events ?? [];
@@ -389,6 +409,7 @@ function ShiftActivityLog({
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} />
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} /> <Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} /> <Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} /> <Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
@@ -446,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} /> <Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} /> <Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
<ChargeFigures charges={report.chargesByModuleMinor} cur={report.currency} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
@@ -472,6 +494,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5"> <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} /> <Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} /> <Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
@@ -513,6 +536,7 @@ function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void })
<span /> <span />
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} /> <Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} /> <Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
<ChargeFigures charges={x.chargesByModuleMinor} cur={x.currency} spacer />
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */} {/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
<span /> <span />
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub /> <Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
+13 -4
View File
@@ -213,6 +213,9 @@ export interface ManagedRole {
name: string; name: string;
builtin: boolean; builtin: boolean;
permissions: Permission[]; permissions: Permission[];
/** The manifest JOBS this role follows (composed from their chips). A later release
* that grows a job shows the role as "behind" it — re-applied with one click. */
jobs: string[];
userCount: number; userCount: number;
} }
@@ -241,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> {
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> { export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
return apiFetch("/api/roles"); return apiFetch("/api/roles");
} }
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> { export function createRole(body: { name: string; permissions: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
} }
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> { export function updateRole(id: string, body: { name?: string; permissions?: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) }); return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
} }
export function deleteRole(id: string): Promise<{ ok: boolean }> { export function deleteRole(id: string): Promise<{ ok: boolean }> {
@@ -1096,6 +1099,9 @@ export interface ShiftSourceSplit {
subscriptionTotalMinor: number; subscriptionTotalMinor: number;
subscriptionSalesMinor: number; subscriptionSalesMinor: number;
subscriptionWindowMinor: number; subscriptionWindowMinor: number;
/** Module money that rode this till's tickets (a booth-paid wash), by module id.
* Inside cash+card, OUTSIDE the ticket bucket. Absent on pre-2026-09 reports. */
chargesByModuleMinor?: Partial<Record<string, number>>;
} }
export interface ShiftReport extends ShiftSourceSplit { export interface ShiftReport extends ShiftSourceSplit {
@@ -1331,7 +1337,7 @@ export interface DeviceStatus {
category: "access" | "reader" | "camera" | "printer" | "vision"; category: "access" | "reader" | "camera" | "printer" | "vision";
/** Role/direction token for the footer label (NOT the vendor) — the client /** Role/direction token for the footer label (NOT the vendor) — the client
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */ * localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null; roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
state: "ready" | "degraded" | "offline"; state: "ready" | "degraded" | "offline";
detail?: string; detail?: string;
checkedAt: string; checkedAt: string;
@@ -1351,15 +1357,18 @@ export type { AppLogRecord };
/** Recent ledger events, newest first (default 100, max 1000). Used for the /** Recent ledger events, newest first (default 100, max 1000). Used for the
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO) * booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
* scopes to events at/after that instant — the booth passes the current shift's * scopes to events at/after that instant — the booth passes the current shift's
* start so the feed shows ONLY this shift's activity. */ * start so the feed shows ONLY this shift's activity. `till` keeps one till's activity
* (the server applies the shared tillOfEvent rule) — a shift's log is per till. */
export function fetchEvents( export function fetchEvents(
limit = 100, limit = 100,
since?: string, since?: string,
until?: string, until?: string,
till?: TillId,
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> { ): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
const qs = new URLSearchParams({ limit: String(limit) }); const qs = new URLSearchParams({ limit: String(limit) });
if (since) qs.set("since", since); if (since) qs.set("since", since);
if (until) qs.set("until", until); if (until) qs.set("until", until);
if (till) qs.set("till", till);
return apiFetch(`/api/events?${qs.toString()}`); return apiFetch(`/api/events?${qs.toString()}`);
} }
+29
View File
@@ -67,6 +67,18 @@ export const en: Catalog = {
carwash: "Car wash", carwash: "Car wash",
}, },
}, },
vehicleClass: {
car: "car",
sedan: "sedan",
hatchback: "hatchback",
suv: "SUV",
minivan: "minivan",
pickup: "pickup",
van: "van",
truck: "truck",
bus: "bus",
motorcycle: "motorcycle",
},
wash: { wash: {
tillTitle: "Wash till", tillTitle: "Wash till",
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.", tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
@@ -124,6 +136,17 @@ export const en: Catalog = {
sponsorship: "Parking discount", sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.", sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
sponsorshipLabel: "Car wash", sponsorshipLabel: "Car wash",
// Vision (advisory): the entry camera's body-type read, mapped to a site category.
visionSaw: "Camera saw",
visionUnmapped: "not mapped to a category",
visionClasses: "Camera classes",
reviewTitle: "Remote review",
reviewOff: "off — no collector configured for this booth",
reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned",
reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.",
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
visionThreshold: "Camera confidence to flag a downgrade",
visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.",
}, },
update: { update: {
available: "Update available", available: "Update available",
@@ -232,6 +255,7 @@ export const en: Catalog = {
mixed: "entry/exit", mixed: "entry/exit",
lane: "at lane", lane: "at lane",
booth: "at booth", booth: "at booth",
wash: "at wash desk",
}, },
state: { state: {
ready: "ready", ready: "ready",
@@ -384,6 +408,7 @@ export const en: Catalog = {
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)", "entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry", "entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
"entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}", "entry.duplicatePlate": "Possible duplicate entry — plate {{plate}} is already inside under ticket {{otherIdentity}}",
"carwash.categoryDowngrade": "Wash category downgraded — camera saw {{visionClass}} ({{visionCategory}}), operator {{operator}} chose {{chosenCategory}}",
"exit.refused.closed": "Exit refused — session already closed", "exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — unknown ticket", "exit.refused.noSession": "Exit refused — unknown ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)", "exit.refused.unpaid": "Exit refused — not paid (take payment first)",
@@ -916,6 +941,9 @@ export const en: Catalog = {
jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.", jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.",
lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?", lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?",
lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.", lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.",
lintJobBehind: "Behind \"{{job}}\": this release added {{missing}} to the job. Tap the job chip off and on to take it, or tick it below.",
behind: "behind {{job}}",
reapply: "Update to job",
}, },
jobs: { jobs: {
"booth-operator": "Booth operator", "booth-operator": "Booth operator",
@@ -960,6 +988,7 @@ export const en: Catalog = {
card: "Card:", card: "Card:",
srcTickets: "Tickets:", srcTickets: "Tickets:",
srcSubscriptions: "Subscriptions:", srcSubscriptions: "Subscriptions:",
srcOnTicket: "{{module}} (on ticket):",
srcSubWindow: "out-of-window", srcSubWindow: "out-of-window",
drawerSection: "— Drawer —", drawerSection: "— Drawer —",
openingFloat: "Opening cash:", openingFloat: "Opening cash:",
+28
View File
@@ -70,6 +70,18 @@ export const sq = {
carwash: "Lavazh", carwash: "Lavazh",
}, },
}, },
vehicleClass: {
car: "veturë",
sedan: "sedan",
hatchback: "hatchback",
suv: "SUV",
minivan: "minivan",
pickup: "pikap",
van: "furgon",
truck: "kamion",
bus: "autobus",
motorcycle: "motor",
},
wash: { wash: {
tillTitle: "Arka e lavazhit", tillTitle: "Arka e lavazhit",
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.", tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
@@ -127,6 +139,16 @@ export const sq = {
sponsorship: "Zbritje parkimi", sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.", sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh", sponsorshipLabel: "Lavazh",
visionSaw: "Kamera pa",
visionUnmapped: "pa kategori të lidhur",
visionClasses: "Klasat e kamerës",
reviewTitle: "Shqyrtim në distancë",
reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë",
reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura",
reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.",
visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.",
visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie",
visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.",
}, },
update: { update: {
available: "Përditësim i disponueshëm", available: "Përditësim i disponueshëm",
@@ -235,6 +257,7 @@ export const sq = {
mixed: "hyrje/dalje", mixed: "hyrje/dalje",
lane: "në korsi", lane: "në korsi",
booth: "në kabinë", booth: "në kabinë",
wash: "në lavazh",
}, },
state: { state: {
ready: "gati", ready: "gati",
@@ -388,6 +411,7 @@ export const sq = {
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)", "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", "entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
"entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}", "entry.duplicatePlate": "Hyrje e dyfishtë e mundshme — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
"carwash.categoryDowngrade": "Kategoria e lavazhit u ul — kamera pa {{visionClass}} ({{visionCategory}}), operatori {{operator}} zgjodhi {{chosenCategory}}",
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë", "exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur", "exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)", "exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
@@ -930,6 +954,9 @@ export const sq = {
jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.", jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.",
lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?", lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?",
lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.", lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.",
lintJobBehind: "Pas \"{{job}}\": ky version i shtoi punës {{missing}}. Hiqe dhe rivendose punën për t'i marrë, ose shënoji poshtë.",
behind: "pas {{job}}",
reapply: "Përditëso sipas punës",
}, },
jobs: { jobs: {
"booth-operator": "Operator kabine", "booth-operator": "Operator kabine",
@@ -974,6 +1001,7 @@ export const sq = {
card: "Kartë:", card: "Kartë:",
srcTickets: "Bileta:", srcTickets: "Bileta:",
srcSubscriptions: "Abonime:", srcSubscriptions: "Abonime:",
srcOnTicket: "{{module}} (në biletë):",
srcSubWindow: "jashtë orarit", srcSubWindow: "jashtë orarit",
drawerSection: "— Arka —", drawerSection: "— Arka —",
openingFloat: "Arka fillestare:", openingFloat: "Arka fillestare:",
+73 -9
View File
@@ -1,16 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared"; import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared";
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js"; import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
import { StationForm, defaultProgram } from "../../ValidationSetup.js"; import { StationForm, defaultProgram } from "../../ValidationSetup.js";
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js"; import { fetchCarwashReviewStatus, fetchCarwashSettings, saveCarwashSettings, type CarwashReviewStatus, type CarwashSettingsView } from "./api.js";
// Setup → Car wash: the master data (vehicle categories, services, the category × // Setup → Car wash: the master data (vehicle categories, services, the category ×
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a // service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
// validation program (id "carwash"), composed with the same editor the merchant // validation program (id "carwash"), composed with the same editor the merchant
// programs use. See wiki/decisions/venue-modules.md ("v1 answers"). // programs use. See wiki/decisions/venue-modules.md ("v1 answers").
type Item = { id?: string; name: string; active: boolean }; type Item = { id?: string; name: string; active: boolean; visionClasses?: VehicleClass[] };
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, "")); const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
const toMinor = (s: string): number | null => { const toMinor = (s: string): number | null => {
@@ -25,18 +25,34 @@ function ListEditor({
items, items,
onChange, onChange,
addLabel, addLabel,
visionMap,
}: { }: {
title: string; title: string;
items: Item[]; items: Item[];
onChange: (items: Item[]) => void; onChange: (items: Item[]) => void;
addLabel: string; addLabel: string;
/** Categories only: offer the vision vocabulary as chips under each row — the site's
* own "car, sedan → Vetura" mapping (venue-modules.md §Vehicle category). The chips
* show the CANONICAL ids (the model's fixed vocabulary, a code constant), never a
* translation, so they read as what they are: not site text (user, 2026-09-06). */
visionMap?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggleClass = (i: number, cls: VehicleClass) =>
onChange(
items.map((x, j) => {
if (j !== i) return x;
const cur = new Set(x.visionClasses ?? []);
cur.has(cls) ? cur.delete(cls) : cur.add(cls);
return { ...x, visionClasses: VEHICLE_CLASSES.filter((c) => cur.has(c)) };
}),
);
return ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div> <div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
{items.map((it, i) => ( {items.map((it, i) => (
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2"> <div key={it.id ?? `new-${i}`} className="grid gap-1">
<div className="flex items-center gap-2">
<input <input
className="input flex-1" className="input flex-1"
value={it.name} value={it.name}
@@ -55,6 +71,26 @@ function ListEditor({
✕ ✕
</button> </button>
</div> </div>
{visionMap && (
<div className="flex flex-wrap items-center gap-1 pl-1">
<span className="mr-1 text-[0.625rem] uppercase tracking-wider text-term-muted" title={t("wash.visionClassesHint")}>{t("wash.visionClasses")}</span>
{VEHICLE_CLASSES.map((cls) => {
const on = (it.visionClasses ?? []).includes(cls);
return (
<button
key={cls}
type="button"
className={`btn btn-sm font-mono lowercase ${on ? "btn-primary" : "btn-ghost"}`}
title={t(`vehicleClass.${cls}`)}
onClick={() => toggleClass(i, cls)}
>
{cls}
</button>
);
})}
</div>
)}
</div>
))} ))}
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}> <button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
+ {addLabel} + {addLabel}
@@ -73,21 +109,26 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const [prices, setPrices] = useState<Record<string, string>>({}); const [prices, setPrices] = useState<Record<string, string>>({});
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */ /** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
const [payAt, setPayAt] = useState<CarWashPayAt>("booth"); const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
/** Confidence floor for a vision read to flag a downgrade (percent, as typed). */
const [threshold, setThreshold] = useState("80");
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [program, setProgram] = useState<ValidationProgramView | null>(null); const [program, setProgram] = useState<ValidationProgramView | null>(null);
const [review, setReview] = useState<CarwashReviewStatus | null>(null);
function load() { function load() {
fetchCarwashSettings() fetchCarwashSettings()
.then((s) => { .then((s) => {
setSettings(s); setSettings(s);
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active }))); setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active, visionClasses: c.visionClasses })));
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active }))); setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {}; const p: Record<string, string> = {};
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor); for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p); setPrices(p);
setPayAt(s.payAt); setPayAt(s.payAt);
setThreshold(String(Math.round(s.visionThreshold * 100)));
}) })
.catch((e) => setMsg((e as Error).message)); .catch((e) => setMsg((e as Error).message));
fetchCarwashReviewStatus().then(setReview).catch(() => {});
fetchValidationPrograms() fetchValidationPrograms()
.then((r) => { .then((r) => {
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID); const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
@@ -103,7 +144,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
setMsg(null); setMsg(null);
try { try {
const listBody = { const listBody = {
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })), categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active, visionClasses: c.visionClasses ?? [] })),
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })), services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
}; };
// New rows have no id until the server assigns one, and the price matrix is keyed // New rows have no id until the server assigns one, and the price matrix is keyed
@@ -133,15 +174,18 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v }); if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
}), }),
); );
const thr = Number(threshold);
const saved = await saveCarwashSettings({ const saved = await saveCarwashSettings({
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })), categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active, visionClasses: c.visionClasses ?? [] })),
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })), services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
prices: priceRows, prices: priceRows,
payAt, payAt,
...(Number.isFinite(thr) && thr >= 0 && thr <= 100 ? { visionThreshold: thr / 100 } : {}),
}); });
setSettings(saved); setSettings(saved);
setPayAt(saved.payAt); setPayAt(saved.payAt);
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active }))); setThreshold(String(Math.round(saved.visionThreshold * 100)));
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active, visionClasses: c.visionClasses })));
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active }))); setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {}; const p: Record<string, string> = {};
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor); for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
@@ -158,7 +202,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
<div className="mt-6 flex flex-wrap items-start gap-6"> <div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-2xl p-4"> <section className="card w-full max-w-2xl p-4">
<div className="grid gap-4"> <div className="grid gap-4">
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} /> <ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} visionMap />
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} /> <ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
<div> <div>
@@ -215,6 +259,26 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
</div> </div>
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span> <span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
</div> </div>
<div className="field">
<span className="label">{t("wash.visionThreshold")}</span>
<div className="flex items-center gap-2">
<input className="input w-20 text-right tabular-nums" inputMode="numeric" value={threshold} disabled={!canEdit} onChange={(e) => setThreshold(e.target.value)} />
<span className="text-[0.75rem] text-term-muted">%</span>
</div>
<span className="hint">{t("wash.visionThresholdHint")}</span>
</div>
{review && (
<div className="field">
<span className="label">{t("wash.reviewTitle")}</span>
<span className="text-[0.75rem] tabular-nums">
{review.enabled
? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed })
: t("wash.reviewOff")}
{review.enabled && review.lastError && <span className="ml-2 text-term-amber">{review.lastError}</span>}
</span>
<span className="hint">{t("wash.reviewHint")}</span>
</div>
)}
{canEdit && ( {canEdit && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+21 -1
View File
@@ -73,7 +73,10 @@ export function WashDesk() {
setMsg(null); setMsg(null);
if (!ticket.trim()) return; if (!ticket.trim()) return;
try { try {
setLookup(await lookupCarwashTicket(ticket)); const found = await lookupCarwashTicket(ticket);
setLookup(found);
// Vision proposes, the operator decides: pre-select the mapped category.
if (found.suggestedCategoryId) setCategoryId(found.suggestedCategoryId);
} catch (err) { } catch (err) {
setMsg((err as Error).message); setMsg((err as Error).message);
} }
@@ -170,6 +173,23 @@ export function WashDesk() {
<span className="text-term-muted">{t("wash.plate")}</span> <span className="text-term-muted">{t("wash.plate")}</span>
<span className="font-mono">{lookup.plate ?? "—"}</span> <span className="font-mono">{lookup.plate ?? "—"}</span>
</div> </div>
{lookup.vision && (
<div className="flex items-center justify-between gap-2">
<span className="text-term-muted">{t("wash.visionSaw")}</span>
<span className="flex items-center gap-2">
{lookup.vision.snapshotId && (
<img src={`/api/snapshots/${lookup.vision.snapshotId}`} alt="" className="h-8 w-12 rounded-sm object-cover" />
)}
<span>
{t(`vehicleClass.${lookup.vision.bodyType}`)}
<span className="ml-1 tabular-nums text-term-muted">{Math.round(lookup.vision.confidence * 100)}%</span>
{lookup.suggestedCategoryId
? <span className="ml-1 text-term-amber">→ {categories.find((c) => c.id === lookup.suggestedCategoryId)?.name ?? lookup.suggestedCategoryId}</span>
: <span className="ml-1 text-term-muted">{t("wash.visionUnmapped")}</span>}
</span>
</span>
</div>
)}
{lookup.enteredAt && ( {lookup.enteredAt && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-term-muted">{t("wash.enteredAt")}</span> <span className="text-term-muted">{t("wash.enteredAt")}</span>
+22 -2
View File
@@ -1,4 +1,4 @@
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared"; import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender, VehicleClass, VehicleRead } from "@parking/shared";
import { apiFetch } from "../../api.js"; import { apiFetch } from "../../api.js";
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core // The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
@@ -15,14 +15,34 @@ export interface CarwashTicketLookup {
enteredAt: string | null; enteredAt: string | null;
currency: string | null; currency: string | null;
orders: CarwashOrderView[]; orders: CarwashOrderView[];
/** What the camera saw at entry (advisory) and the category the site mapping
* suggests — pre-selected on the desk; the operator may change it. */
vision: VehicleRead | null;
suggestedCategoryId: string | null;
} }
export interface CarwashSettingsBody { export interface CarwashSettingsBody {
categories?: { id?: string; name: string; active?: boolean }[]; categories?: { id?: string; name: string; active?: boolean; visionClasses?: VehicleClass[] }[];
services?: { id?: string; name: string; active?: boolean }[]; services?: { id?: string; name: string; active?: boolean }[];
prices?: { categoryId: string; serviceId: string; priceMinor: number }[]; prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
/** Where wash money is taken at this site (site-level; the desk no longer asks). */ /** Where wash money is taken at this site (site-level; the desk no longer asks). */
payAt?: CarWashPayAt; payAt?: CarWashPayAt;
/** Confidence floor (0–1) for a vision class to flag a category downgrade. */
visionThreshold?: number;
}
/** The review outbox's health (Setup → Car wash). */
export interface CarwashReviewStatus {
enabled: boolean;
boothId: string | null;
queued: number;
sent: number;
failed: number;
lastSentAt: string | null;
lastError: string | null;
}
export function fetchCarwashReviewStatus(): Promise<CarwashReviewStatus> {
return apiFetch("/api/carwash/review/status");
} }
export function fetchCarwashSettings(): Promise<CarwashSettingsView> { export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
+47
View File
@@ -0,0 +1,47 @@
# The Car Wash REVIEW COLLECTOR — deployed on the REVIEWER's host (art-docker-station), NOT
# on a booth. Its own Komodo stack ("wash-collector" in komodo/resources.toml) points at this
# file alone, so nothing here reaches a booth and nothing of the booth stack reaches this
# host. See wiki/concepts/vision-review-outbox.md.
#
# Reachability: booths POST to /ingest over the Netbird overlay only. Bind the published
# port to the host's OVERLAY address (COLLECTOR_BIND), never 0.0.0.0 on a host that also
# has a public interface. The Netbird policy should allow booths → this host:8090 and
# nothing else on it.
services:
collector:
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-collector:${TAG:-dev}
restart: unless-stopped
ports:
- "${COLLECTOR_BIND:-127.0.0.1}:8090:8090"
environment:
# "<boothId>:<token>" pairs — one per booth, the booth's CARWASH_REVIEW_TOKEN under its
# pseudonymous CARWASH_REVIEW_BOOTH_ID. A Komodo secret reference in the stack env.
COLLECTOR_BOOTH_TOKENS: ${COLLECTOR_BOOTH_TOKENS:?set COLLECTOR_BOOTH_TOKENS in the stack env}
# The single reviewer login (HTTP Basic over the overlay).
COLLECTOR_REVIEWER_USER: ${COLLECTOR_REVIEWER_USER:-reviewer}
COLLECTOR_REVIEWER_PASS: ${COLLECTOR_REVIEWER_PASS:?set COLLECTOR_REVIEWER_PASS in the stack env}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- collector-data:/data
# Phase B trainer — a one-off job on this host's GPU, NOT a service (profile "train": it
# only runs when asked: `docker compose --profile train run --rm trainer`). Reads the
# collector's export + crops straight off the same volume; writes the ONNX classifier the
# vision image then bakes in. The image/script are the next increment; this is the seam.
# trainer:
# image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-trainer:${TAG:-dev}
# profiles: ["train"]
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# volumes:
# - collector-data:/data:ro
# - ./models:/out
volumes:
collector-data:
+8
View File
@@ -28,6 +28,11 @@ services:
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it # off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md. # offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
BACKUP_KEY: ${BACKUP_KEY:-} BACKUP_KEY: ${BACKUP_KEY:-}
# Car Wash review outbox: collector URL + per-booth token + pseudonymous booth id, all
# three or off. See apps/server/.env.example + wiki/concepts/vision-review-outbox.md.
CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-}
CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-}
CARWASH_REVIEW_BOOTH_ID: ${CARWASH_REVIEW_BOOTH_ID:-}
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT, # CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators # so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook". # CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
@@ -55,6 +60,9 @@ services:
environment: environment:
# Engine: stub (no models) by default; prod override sets fast_alpr. # Engine: stub (no models) by default; prod override sets fast_alpr.
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub} VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
# Vehicle stage (Car Wash category suggestion): the image bakes YOLOX-S at this path.
# Set the var to an EMPTY value in the stack env to switch the stage off.
VISION_VEHICLE_MODEL_PATH: ${VISION_VEHICLE_MODEL_PATH-/app/models/yolox_s.onnx}
networks: networks:
- parking - parking
+43
View File
@@ -91,6 +91,12 @@ COOKIE_SECURE=0
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md. # this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
# park-2 pilots the Car Wash module (2026-09-05). # park-2 pilots the Car Wash module (2026-09-05).
MODULES_ENTITLED=parking,carwash MODULES_ENTITLED=parking,carwash
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md): the collector's ingest URL
# on the Netbird overlay, this booth's pseudonymous id, and its token — the SAME secret the
# wash-collector stack lists under that id. Leave all three unset to keep the outbox off.
#CARWASH_REVIEW_URL=http://docker-station.nb.infra:8090/ingest
#CARWASH_REVIEW_BOOTH_ID=booth-2
#CARWASH_REVIEW_TOKEN=[[wash_review_token_booth_2]]
VISION_ENABLED=1 VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by # Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach # platform-ws.ts, since the native WS plugin has no page context to auto-attach
@@ -100,3 +106,40 @@ JWT_SECRET=[[park_2_jwt_secret]]
EVENT_SIGNING_KEY=[[park_2_event_signing_key]] EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
BACKUP_KEY=[[park_2_backup_key]] BACKUP_KEY=[[park_2_backup_key]]
""" """
##############################################################################
# Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station),
# NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name
# ONLY docker-compose.collector.yml, so nothing booth-side lands here and nothing here
# lands on a booth. Booths reach it over the Netbird overlay only (bind to the overlay
# address). Its own secrets. See wiki/concepts/vision-review-outbox.md.
##############################################################################
[[stack]]
name = "wash-collector"
[stack.config]
server = "art-docker-station"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "stage"
file_paths = [
"docker-compose.collector.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
# Pinned like the booths: bump to the stage-<sha> that carries the collector.
TAG=stage-REPLACE
# The host's NETBIRD address (an IP: Docker port bindings take no hostname) — the ingest port
# is published on the overlay only. Booths reach it by its Netbird DNS name.
COLLECTOR_BIND=100.75.184.156
# "<boothId>:<token>" pairs, one per booth. ONE secret per booth, referenced here AND in
# that booth's own stack as its CARWASH_REVIEW_TOKEN — one value, two consumers, nothing
# to keep in sync, and rotating a booth touches one secret. The booth id is the booth's
# pseudonymous CARWASH_REVIEW_BOOTH_ID, never a site name. Add a pair per booth.
COLLECTOR_BOOTH_TOKENS=booth-2:[[wash_review_token_booth_2]]
COLLECTOR_REVIEWER_USER=reviewer
COLLECTOR_REVIEWER_PASS=[[wash_collector_reviewer_pass]]
"""
+10
View File
@@ -0,0 +1,10 @@
-- Roles remember the manifest JOBS they were composed from (venue-modules.md §"Permissions
-- matrix", move 2) so a role built from a job chip can be flagged and re-applied when a
-- later release grows that job's bundle. The permission grid stays the enforcement layer.
CREATE TABLE `role_jobs` (
`role_id` text NOT NULL,
`job_id` text NOT NULL,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `role_jobs_role_id_job_id_unique` ON `role_jobs` (`role_id`,`job_id`);
@@ -0,0 +1,15 @@
-- Car Wash: advisory vehicle category from vision (venue-modules.md §Vehicle category from
-- vision). Categories map the vision vocabulary onto the site's own price categories; an
-- order records what the camera saw, the category it suggested and the anomaly signed on a
-- downgrade; the config carries the confidence floor. Recorded only — never blocks.
ALTER TABLE `carwash_categories` ADD `vision_classes` text DEFAULT '[]' NOT NULL;
--> statement-breakpoint
ALTER TABLE `carwash_orders` ADD `vision_class` text;
--> statement-breakpoint
ALTER TABLE `carwash_orders` ADD `vision_confidence` real;
--> statement-breakpoint
ALTER TABLE `carwash_orders` ADD `vision_category_id` text;
--> statement-breakpoint
ALTER TABLE `carwash_orders` ADD `downgrade_event_id` text;
--> statement-breakpoint
ALTER TABLE `carwash_config` ADD `vision_threshold` real DEFAULT 0.8 NOT NULL;
@@ -0,0 +1,17 @@
-- Car Wash review outbox (wiki/concepts/vision-review-outbox.md): plate-blurred vehicle
-- crops + the operator's category choice, queued for a trusted remote reviewer and drained
-- one-way over the private overlay. The image is cleared once delivered.
CREATE TABLE `carwash_review_outbox` (
`id` text PRIMARY KEY NOT NULL,
`order_id` text NOT NULL,
`created_at` text NOT NULL,
`status` text DEFAULT 'queued' NOT NULL,
`attempts` integer DEFAULT 0 NOT NULL,
`next_attempt_at` text,
`last_error` text,
`sent_at` text,
`image` blob,
`payload` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `carwash_review_outbox_status_idx` ON `carwash_review_outbox` (`status`,`next_attempt_at`);
+21
View File
@@ -204,6 +204,27 @@
"when": 1788605000000, "when": 1788605000000,
"tag": "0028_carwash_config", "tag": "0028_carwash_config",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1788690000000,
"tag": "0029_role_jobs",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1788700000000,
"tag": "0030_carwash_vision",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1788710000000,
"tag": "0031_carwash_review_outbox",
"breakpoints": true
} }
] ]
} }
+51 -1
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { blob, integer, primaryKey, real, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
// Schema notes: // Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md): // - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
@@ -53,6 +53,24 @@ export const rolePermissions = sqliteTable(
}), }),
); );
/** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the
* manifest job presets the admin composed it from. Remembered so a later release that
* grows a job's bundle can be surfaced ("this role is behind the Wash operator job")
* and re-applied with one click — never expanded silently at runtime: what a role may
* do is always the explicit `role_permissions` grid. */
export const roleJobs = sqliteTable(
"role_jobs",
{
roleId: text("role_id")
.notNull()
.references(() => roles.id),
jobId: text("job_id").notNull(),
},
(t) => ({
uniq: unique().on(t.roleId, t.jobId),
}),
);
export const users = sqliteTable("users", { export const users = sqliteTable("users", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
username: text("username").notNull().unique(), username: text("username").notNull().unique(),
@@ -635,6 +653,9 @@ export const carwashCategories = sqliteTable("carwash_categories", {
name: text("name").notNull(), name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0), sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true), active: integer("active", { mode: "boolean" }).notNull().default(true),
/** The vision vocabulary classes this category covers (JSON array of VehicleClass) —
* the site's own mapping ("car, sedan → Vetura"). Empty = never suggested by vision. */
visionClasses: text("vision_classes", { mode: "json" }).$type<string[]>().notNull().default(sql`'[]'`),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -705,6 +726,13 @@ export const carwashOrders = sqliteTable("carwash_orders", {
voidAt: text("void_at"), voidAt: text("void_at"),
voidBy: text("void_by"), voidBy: text("void_by"),
voidReason: text("void_reason"), voidReason: text("void_reason"),
// Vision, advisory (venue-modules.md §Vehicle category): what the camera saw at entry,
// the category the site mapping suggested, and the `anomaly` signed when the operator
// chose a cheaper category above the confidence threshold. Never a tariff input.
visionClass: text("vision_class"),
visionConfidence: real("vision_confidence"),
visionCategoryId: text("vision_category_id"),
downgradeEventId: text("downgrade_event_id"),
}); });
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this /** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
@@ -712,10 +740,32 @@ export const carwashOrders = sqliteTable("carwash_orders", {
export const carwashConfig = sqliteTable("carwash_config", { export const carwashConfig = sqliteTable("carwash_config", {
id: integer("id").primaryKey(), id: integer("id").primaryKey(),
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"), payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
/** Confidence floor (0–1) for a vision class to flag a category downgrade. */
visionThreshold: real("vision_threshold").notNull().default(0.8),
updatedAt: text("updated_at"), updatedAt: text("updated_at"),
updatedBy: text("updated_by"), updatedBy: text("updated_by"),
}); });
/** Car Wash REVIEW OUTBOX (wiki/concepts/vision-review-outbox.md): the operator's category
* choice is a hypothesis, not truth — each wash order with a vehicle read queues a
* plate-blurred vehicle CROP + the choice for a trusted remote reviewer, drained one-way
* over the private overlay when it is up. Never blocks the wash; nothing that names the
* site leaves the booth. The image is dropped once delivered. */
export const carwashReviewOutbox = sqliteTable("carwash_review_outbox", {
id: text("id").primaryKey(),
orderId: text("order_id").notNull(),
createdAt: text("created_at").notNull(),
status: text("status", { enum: ["queued", "sent", "failed"] }).notNull().default("queued"),
attempts: integer("attempts").notNull().default(0),
nextAttemptAt: text("next_attempt_at"),
lastError: text("last_error"),
sentAt: text("sent_at"),
/** The JPEG crop (plate blurred). Null once sent. */
image: blob("image").$type<Buffer>(),
/** What the collector receives beside the image (no site name, no plate, no operator name). */
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>().notNull(),
});
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect; export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
export type CarwashServiceRow = typeof carwashServices.$inferSelect; export type CarwashServiceRow = typeof carwashServices.$inferSelect;
export type CarwashPriceRow = typeof carwashPrices.$inferSelect; export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
// --- shared driver config fields ---------------------------------------------- // --- shared driver config fields ----------------------------------------------
// Role + failover are identical across ESC/POS printers; defined here so each // Role + failover are identical across ESC/POS printers; defined here so each
// driver shares them. See wiki/concepts/printer-roles-failover.md. // driver shares them. See wiki/concepts/printer-roles-failover.md.
export type PrinterRole = "entry-dispenser" | "booth-receipt"; export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
// --- shared printer config fields (transport) --------------------------------- // --- shared printer config fields (transport) ---------------------------------
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each // TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
@@ -128,8 +128,9 @@ const roleField: ConfigField = {
label: "Entry dispenser (outside / at the lane)", label: "Entry dispenser (outside / at the lane)",
}, },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { value: "booth-receipt", label: "Booth printer (receipts + backup)" },
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
], ],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.", help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
}; };
const rankField: ConfigField = { const rankField: ConfigField = {
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
} }
} }
/** Type guard: does this device carry a printer role (entry vs. booth)? */ /** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
export type PrinterRole = "entry-dispenser" | "booth-receipt"; * the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
const roleField: ConfigField = { const roleField: ConfigField = {
key: "role", key: "role",
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
label: "Entry dispenser (outside / at the lane)", label: "Entry dispenser (outside / at the lane)",
}, },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" }, { value: "booth-receipt", label: "Booth printer (receipts + backup)" },
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
], ],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.", help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
}; };
const rankField: ConfigField = { const rankField: ConfigField = {
+1
View File
@@ -27,6 +27,7 @@ export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
export { export {
orderForRole, orderForRole,
printWithFailover, printWithFailover,
printerRoleOf,
NoPrinterAvailableError, NoPrinterAvailableError,
type PrinterInstance, type PrinterInstance,
} from "./printer-routing.js"; } from "./printer-routing.js";
@@ -27,6 +27,19 @@ describe("orderForRole", () => {
expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]); expect(orderForRole(printers, "booth-receipt").map((p) => p.id)).toEqual(["booth"]);
}); });
it("wash-desk job: the desk printer first, the booth printer as fallback, never the dispenser", () => {
const printers = [inst("disp", "entry-dispenser"), inst("booth", "booth-receipt"), inst("desk", "wash-desk")];
expect(orderForRole(printers, "wash-desk").map((p) => p.id)).toEqual(["desk", "booth"]);
// A site without a desk printer keeps printing wash slips in the booth.
expect(orderForRole(printers.slice(0, 2), "wash-desk").map((p) => p.id)).toEqual(["booth"]);
});
it("nothing ever falls back TO the wash desk (booth receipts and entry tickets stay off it)", () => {
const printers = [inst("desk", "wash-desk")];
expect(orderForRole(printers, "booth-receipt")).toEqual([]);
expect(orderForRole(printers, "entry-dispenser")).toEqual([]);
});
it("breaks ties by failoverRank (higher first), then id", () => { it("breaks ties by failoverRank (higher first), then id", () => {
const printers = [ const printers = [
inst("b", "entry-dispenser", 1), inst("b", "entry-dispenser", 1),
+12 -1
View File
@@ -26,13 +26,16 @@ export interface PrinterInstance {
* printer is also a fallback for entry tickets, so when an entry ticket is * printer is also a fallback for entry tickets, so when an entry ticket is
* routed, booth-receipt printers follow the entry dispensers. The reverse is * routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser. * deliberately NOT done — a receipt never prints on the outside dispenser.
* The wash desk's slips (its till's Z-report and vouchers) fall back to the booth
* printer the same way — a site without a desk printer keeps printing them in the
* booth, as it did before the role existed. Nothing ever falls back TO the wash desk.
*/ */
export function orderForRole( export function orderForRole(
printers: readonly PrinterInstance[], printers: readonly PrinterInstance[],
wantRole: PrinterRole, wantRole: PrinterRole,
): PrinterInstance[] { ): PrinterInstance[] {
const fallbackRole: PrinterRole | null = const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" ? "booth-receipt" : null; wantRole === "entry-dispenser" || wantRole === "wash-desk" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => { const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2; if (p.role === wantRole) return 2;
@@ -49,6 +52,14 @@ export function orderForRole(
}); });
} }
/** The role a printer's saved config declares — ONE reading of the field, so a
* wash-desk printer is never mistaken for an entry dispenser by a loader that only
* knew two roles. Unknown/absent = entry-dispenser (the field's default). */
export function printerRoleOf(cfg: { role?: unknown } | null | undefined): PrinterRole {
const r = cfg?.role;
return r === "booth-receipt" || r === "wash-desk" ? r : "entry-dispenser";
}
export class NoPrinterAvailableError extends Error { export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) { constructor(public readonly attempts: { id: string; error: string }[]) {
super( super(
+90 -1
View File
@@ -446,6 +446,10 @@ export const REASON_CODES = [
// ticket (e.g. a motion radar dropped the stationary car and re-armed the button). // ticket (e.g. a motion radar dropped the stationary car and re-armed the button).
// Post-hoc + advisory (ANPR never gates); the operator voids the duplicate. // Post-hoc + advisory (ANPR never gates); the operator voids the duplicate.
"entry.duplicatePlate", "entry.duplicatePlate",
// Car Wash: vision read the vehicle as a class that maps to a PRICIER category than the
// one the operator chose, above the site's confidence threshold. Recorded only (never
// blocks, no reason prompt — user, 2026-09-06); the reviewer sees both on one row.
"carwash.categoryDowngrade",
// exit refusals // exit refusals
"exit.refused.closed", "exit.refused.closed",
"exit.refused.noSession", "exit.refused.noSession",
@@ -498,6 +502,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)", "entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
"entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry", "entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry",
"entry.duplicatePlate": "possible duplicate entry — plate {plate} is already inside under ticket {otherIdentity}", "entry.duplicatePlate": "possible duplicate entry — plate {plate} is already inside under ticket {otherIdentity}",
"carwash.categoryDowngrade": "wash category downgraded — camera saw {visionClass} ({visionCategory}), operator {operator} chose {chosenCategory}",
"exit.refused.closed": "exit refused — session already closed", "exit.refused.closed": "exit refused — session already closed",
"exit.refused.noSession": "exit refused — no open session for ticket", "exit.refused.noSession": "exit refused — no open session for ticket",
"exit.refused.unpaid": "exit refused — not paid (take payment first)", "exit.refused.unpaid": "exit refused — not paid (take payment first)",
@@ -1926,6 +1931,42 @@ export function watchPermissions(effective: readonly ModuleId[]): Permission[] {
} }
/** Which tills a role may work more than one of — the composer's "mixes desks" lint. */ /** Which tills a role may work more than one of — the composer's "mixes desks" lint. */
/** The till an event's ACTIVITY belongs to, for a shift's log: a money event names its
* till (`tillOf`); any other event belongs to the till of the module that owns its type
* (a `carwash_order` is wash-desk activity even though no money moved); everything
* else — entries, exits, barrier commands, pre-till events — is the booth's. The
* server's `/api/events?till=` filter and the web feeds share this one rule. */
export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } | null | undefined): TillId {
if (payload?.till) return payload.till;
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
return m?.till ?? BOOTH_TILL;
}
/** A job preset by id, with the module that declares it (null = no such job — e.g. a
* job remembered by a role whose module was removed from the registry). */
export function jobById(id: string): { module: ModuleId; job: JobPreset } | null {
for (const m of MODULES) for (const job of m.jobs) if (job.id === id) return { module: m.id, job };
return null;
}
/** The jobs a role FOLLOWS whose bundle has grown past what the role holds: the role
* was built from the chip, a later release added a permission to the job, and the
* role fell behind. The admin re-applies with one click (or drops the job); the grid
* is never expanded silently. Jobs no longer in the registry are ignored. */
export function jobsBehind(
jobs: readonly string[],
has: (p: Permission) => boolean,
): { job: string; missing: Permission[] }[] {
const out: { job: string; missing: Permission[] }[] = [];
for (const id of jobs) {
const found = jobById(id);
if (!found) continue;
const missing = found.job.permissions.filter((p) => !has(p));
if (missing.length > 0) out.push({ job: id, missing });
}
return out;
}
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] { export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift"); return tillsFor(effective, has, "shift");
} }
@@ -1988,8 +2029,48 @@ export interface ChargeLine {
/** Setup → Car wash: the admin-maintained master data, as read/written by /** Setup → Car wash: the admin-maintained master data, as read/written by
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */ * GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
/** What the vision service may call a vehicle's body type — a FIXED vocabulary the site
* maps onto its own price categories (Setup → Car wash: "car, sedan, hatchback → Vetura").
* Phase A (a COCO detector) only ever emits car/truck/bus/motorcycle; the finer classes
* arrive with the body-type classifier (venue-modules.md §Vehicle category from vision). */
export const VEHICLE_CLASSES = [
"car", "sedan", "hatchback", "suv", "minivan", "pickup", "van", "truck", "bus", "motorcycle",
] as const;
export type VehicleClass = (typeof VEHICLE_CLASSES)[number];
export function isVehicleClass(v: unknown): v is VehicleClass {
return typeof v === "string" && (VEHICLE_CLASSES as readonly string[]).includes(v);
}
/** Below this confidence a vision class is shown but never flags a downgrade. Site
* config (Setup → Car wash); this is the default. */
export const CARWASH_VISION_THRESHOLD_DEFAULT = 0.8;
/** The advisory vehicle read for a session, off the entry snapshot (unsigned device
* event, like the plate). Never a tariff input by itself. */
export interface VehicleRead {
readonly bodyType: VehicleClass;
readonly confidence: number;
readonly snapshotId: string | null;
/** The vehicle's box and the plate's box as FRACTIONS of the frame (0–1), so they fit
* any resized copy of the snapshot. Absent on reads made before boxes were kept. */
readonly box?: NormBox | null;
readonly plateBox?: NormBox | null;
}
/** A box as fractions of the frame it was found in (x1,y1 top-left; 0–1). */
export interface NormBox {
readonly x1: number;
readonly y1: number;
readonly x2: number;
readonly y2: number;
}
export function isNormBox(v: unknown): v is NormBox {
if (!v || typeof v !== "object") return false;
const b = v as Record<string, unknown>;
return ["x1", "y1", "x2", "y2"].every((k) => typeof b[k] === "number" && (b[k] as number) >= 0 && (b[k] as number) <= 1);
}
export interface CarwashSettingsView { export interface CarwashSettingsView {
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[]; readonly categories: { id: string; name: string; sortOrder: number; active: boolean; visionClasses: VehicleClass[] }[];
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[]; readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
/** One entry per priced (category, service) pair. */ /** One entry per priced (category, service) pair. */
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[]; readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
@@ -1997,6 +2078,8 @@ export interface CarwashSettingsView {
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the /** Where wash money is taken at this site (booth = on the parking ticket; bay = the
* wash operator's till). Site-level; the desk no longer asks per order. */ * wash operator's till). Site-level; the desk no longer asks per order. */
readonly payAt: CarWashPayAt; readonly payAt: CarWashPayAt;
/** Confidence floor for a vision class to flag a downgrade (0–1). */
readonly visionThreshold: number;
} }
/** A wash order as the desk sees it (GET /api/carwash/orders). */ /** A wash order as the desk sees it (GET /api/carwash/orders). */
@@ -2024,6 +2107,12 @@ export interface CarwashOrderView {
readonly validationEventId: string | null; readonly validationEventId: string | null;
readonly voidBy: string | null; readonly voidBy: string | null;
readonly voidReason: string | null; readonly voidReason: string | null;
/** What the camera saw at entry (advisory), the category it mapped to, and the
* anomaly signed when the operator chose a cheaper one. Null when vision read nothing. */
readonly visionClass: VehicleClass | null;
readonly visionConfidence: number | null;
readonly visionCategoryId: string | null;
readonly downgradeEventId: string | null;
} }
export function isModuleId(v: unknown): v is ModuleId { export function isModuleId(v: unknown): v is ModuleId {
+52
View File
@@ -18,6 +18,37 @@ importers:
specifier: 6.0.3 specifier: 6.0.3
version: 6.0.3 version: 6.0.3
apps/collector:
dependencies:
'@fastify/multipart':
specifier: ^9.2.1
version: 9.4.0
'@parking/shared':
specifier: workspace:*
version: link:../../packages/shared
better-sqlite3:
specifier: 12.10.1
version: 12.10.1
fastify:
specifier: 5.8.5
version: 5.8.5
devDependencies:
'@types/better-sqlite3':
specifier: 7.6.13
version: 7.6.13
'@types/node':
specifier: 25.9.3
version: 25.9.3
tsx:
specifier: 4.22.4
version: 4.22.4
typescript:
specifier: 6.0.3
version: 6.0.3
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@25.9.3)(jsdom@25.0.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
apps/desktop: apps/desktop:
dependencies: dependencies:
'@tauri-apps/plugin-process': '@tauri-apps/plugin-process':
@@ -744,12 +775,18 @@ packages:
'@fastify/ajv-compiler@4.0.5': '@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/busboy@3.2.2':
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
'@fastify/cors@11.2.0': '@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
'@fastify/deepmerge@3.2.1':
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
'@fastify/error@4.2.0': '@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -765,6 +802,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1': '@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/multipart@9.4.0':
resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -3380,6 +3420,8 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.2 fast-uri: 3.1.2
'@fastify/busboy@3.2.2': {}
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
dependencies: dependencies:
cookie: 1.1.1 cookie: 1.1.1
@@ -3390,6 +3432,8 @@ snapshots:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
toad-cache: 3.7.1 toad-cache: 3.7.1
'@fastify/deepmerge@3.2.1': {}
'@fastify/error@4.2.0': {} '@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3': '@fastify/fast-json-stringify-compiler@5.0.3':
@@ -3410,6 +3454,14 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
'@fastify/multipart@9.4.0':
dependencies:
'@fastify/busboy': 3.2.2
'@fastify/deepmerge': 3.2.1
'@fastify/error': 4.2.0
fastify-plugin: 5.1.0
secure-json-parse: 4.1.0
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
dependencies: dependencies:
'@fastify/forwarded': 3.0.1 '@fastify/forwarded': 3.0.1
+14 -2
View File
@@ -17,8 +17,10 @@ Each printer instance (a `devices` row, category `printer`) declares a **role**
config: config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes. - **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, AND serves as the - **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, the booth till's
**backup** for entry tickets. Z-reports and vouchers, AND serves as the **backup** for entry tickets (and for wash slips).
- **`wash-desk`** — at the Car Wash desk (added 2026-09-06). Prints the wash till's slips:
its Z-report and drawer vouchers (see [[shift]] §Tills). Nothing else ever prints here.
It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple
printers of the same role deterministically (ties broken by id). printers of the same role deterministically (ties broken by id).
@@ -33,6 +35,16 @@ The reverse is **deliberately not** done: a **receipt** never prints on the outs
Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no
physical sense. physical sense.
For a **wash slip** (`wantRole = wash-desk`): the desk printers first, then the **booth
printer** — a site that has not bought a desk printer keeps printing the wash till's Z-report
and vouchers in the booth, exactly as it did before the role existed. Nothing falls back *to*
the wash desk: a booth receipt or an entry ticket never prints there. `ShiftService` resolves
the role from the till (`TILL_PRINTER_ROLE`: booth → `booth-receipt`, carwash → `wash-desk`)
and keeps one legacy fallback — a booth with a single printer that carries no booth role still
prints its slips on it. `printerRoleOf(config)` is the one reading of the saved `role` field,
so every loader (entry flow, booth receipts, shift slips) agrees on what a printer is; the
device footer shows a desk printer as "at wash desk".
## Where the logic lives ## Where the logic lives
- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't - The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't
+29 -2
View File
@@ -305,8 +305,35 @@ manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
touch the booth by construction; the header button, the hub's start buttons and the touch the booth by construction; the header button, the hub's start buttons and the
drawer switch never offer a till the server would refuse. (A first cut that borrowed drawer switch never offer a till the server would refuse. (A first cut that borrowed
`session:read` as "works the booth till" lived for a few hours and is gone.) `session:read` as "works the booth till" lived for a few hours and is gone.)
- Not done: the per-shift *activity log* is still a time window over the whole chain (money - **The activity log is per till too (2026-09-06).** `tillOfEvent(type, payload)` in
figures are per till, the event list is not); bay slips print on the booth printer. `@parking/shared` extends the money rule to every event: a money event names its till, any
other event belongs to the till of the module that owns its type (a `carwash_order` is
wash-desk activity though no money moved), everything else — entries, exits, barrier
commands, pre-till events — is the booth's. `/api/events?till=` applies the same rule in
SQL (so the page limit applies after the filter); the hub's shift log, the Drawer "today"
panel and the booth feed (history and live pushes) pass their till. The events route also
admits a role without `event:read` that holds a module's feed permission, and then returns
only that module's event types — the live-socket rule, so a wash operator's hub shows the
wash shift's log.
- **The booth Z-report breaks module money out (2026-09-06).** `chargesByModuleMinor`
(`{ carwash: <minor> }`, only when any was taken) sums the `chargeLines` on the till's
payments by owning module; the ticket bucket EXCLUDES it, so `Bileta` is parking money only
and ticket + subscriptions + Σcharges = cash + card. Printed as `Lavazh (në biletë): X` on
the booth slip; the wash till's own slip prints its takings under `Lavazh:` (it sells no
tickets or subscriptions). Signed on `shift_z_report`; older reports read back as `{}`.
- **The wash till prints on its own printer (2026-09-06).** Printer role `wash-desk`; the
wash till's Z-report and vouchers go there, falling back to the booth printer — see
[[printer-roles-failover]].
- **Is a desk printer required? (user, 2026-09-06)** No, in either policy. With *Pagesa në
kabinë* the wash till takes no money at all — orders ride the parking ticket, the booth
prints the receipt and the booth Z carries the wash line — so the wash shift is offered but
reconciles nothing (a desk printer would sit idle). With *Pagesa në lavazh* the till's Z and
vouchers exist and prefer the desk printer, but fall back to the booth's; printing is
best-effort, the signed event is the record. Where a desk printer becomes genuinely
required is a piece that does not exist: a **customer receipt at bay payment** (today the
customer gets nothing on paper). If built, it prints on the desk only, no fallback, and
Setup should warn when the policy is bay payment with no `wash-desk` printer. Also open:
hiding the wash till section on the desk when the policy is booth payment.
## Where the fraud control actually lives ## Where the fraud control actually lives
+98
View File
@@ -0,0 +1,98 @@
---
title: Vision review outbox — harvesting the operator's category choice for a trusted reviewer
type: concept
status: booth side built 2026-09-06; collector pending
related: [venue-modules, opencv-anpr-service, threat-model, append-only-event-chain, network-isolation]
---
# Vision review outbox
**The idea (user, 2026-09-06).** The Car Wash desk asks the operator for the vehicle's category,
and the entry camera now proposes one ([[venue-modules]] §Vehicle category from vision). The
operator's choice is what we would love to train the body-type classifier on — but the
operator **cannot be fully trusted** (mistake or intent; the [[threat-model]]). So the booth
hands each decision to a **trusted party** who reviews the picture and the label remotely,
and *that* verdict is the training label — and, per operator, the honest-mistake / fraud rate.
The booths sit on a private zero-trust overlay (**Netbird**), so the hand-off can go to a very
locked-down collector without exposing anything to the open internet.
## Rules (all enforced in `apps/server/src/modules/carwash/review-outbox.ts`)
1. **Offline-first, never on the intake path.** Creating a wash order *queues* a package (fire
and forget — a failure is a log line); a background loop drains the queue when the overlay
is up. The wash never waits on the network.
2. **One-way.** The booth POSTs; nothing ever comes back into the booth's decisions. The signed
ledger ([[append-only-event-chain]]) stays the only record of what happened at the wash.
Reviewer verdicts stay central and reach the owner as a report per site.
3. **Nothing that names the site leaves the booth.**
- Only the vehicle **crop** (the detector's box + 8 % margin, ≤ 640 px) — no walls, no camera
OSD (date / camera name burned into the frame), no bystanders.
- The **plate is blurred inside the crop** on the booth, from the plate detector's own box.
- The booth is a **pseudonymous id** set at deploy (`CARWASH_REVIEW_BOOTH_ID`); the operator
is a **keyed hash** (`sha256(boothId:username)[:16]`). The mapping back to places and
people is the reviewer's, held off the collector. The dataset export drops even those.
- Boxes are stored as **fractions of the frame** on the vision read, so the crop is cut from
the stored (downscaled) snapshot copy.
4. **The network is not the auth.** A per-booth bearer token on top of the overlay; the booth
can do nothing at the collector but this one POST. Payloads are small (a crop ≈ 50–80 kB).
5. **Data minimisation.** Queued only when there is a vehicle box (no box = no sample); the
image is dropped from the row once delivered; a voided order is abandoned unsent; anything
older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst.
## The package
`multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at,
operator (hash), operatorCategory {id,name}, service, vision {class, confidence, categoryId},
downgraded, image {width, height, plateBlurred} }`. Headers: `Authorization: Bearer <token>`,
`X-Booth-Id`.
## Draining
Every `CARWASH_REVIEW_INTERVAL_SEC` (60): due items oldest-first, 20 per pass. `2xx` → sent
(image cleared). `400/404/413/415/422` → abandoned (the collector refused the package itself).
Anything else (auth not yet fixed, 429, 5xx, timeout, no route) → retry with backoff
`1 min · 2^attempts`, capped at 6 h. `GET /api/carwash/review/status` (site:read) and a line in
Setup → Car wash show queued / delivered / abandoned + the last error.
## Config
`CARWASH_REVIEW_URL`, `CARWASH_REVIEW_TOKEN`, `CARWASH_REVIEW_BOOTH_ID` — all three or the outbox
is off and **nothing is queued** (an unbounded queue nobody drains is worse than none). Set per
booth in the Komodo stack env; compose forwards them.
## The collector — skeleton built 2026-09-06 (`apps/collector`)
A deliberately small Fastify + SQLite service **in this monorepo** (so it imports the payload
contract and the class vocabulary from `@parking/shared` — the two ends cannot drift), delivered
to the reviewer's host by **its own Komodo stack** (`wash-collector` in `komodo/resources.toml`
→ `docker-compose.collector.yml` only; the booth stacks never see it and it never sees booth
services). Image `parking-collector:<branch>-<sha>` from the same workflow as the others.
Three surfaces, nothing else — it must not grow into a fleet console:
- **`POST /ingest`** — bearer token **per booth** (`COLLECTOR_BOOTH_TOKENS`, `boothId:token`
pairs; constant-time compare), `X-Booth-Id` must match the token's booth, multipart `meta` +
`image` (JPEG magic checked, 2 MB cap), `meta` validated field by field against the contract
above (unknown vision class, non-id item, wrong booth → 422), **idempotent on the item id**
(a retry after a lost 2xx → 200 `duplicate`). Stored: `crops/<booth>/<item>.jpg` on the
volume + one `items` row. The booth now also sends `operatorCategory.classes` (the classes
the chosen category covers at that site) so a reviewer's CLASS can be judged against the
operator's CATEGORY without the site's setup.
- **`/review`** (+ `/api/items`, `/api/items/:id/image`, `/api/items/:id/review`, `/api/stats`)
— the reviewer's screen, served by the process itself (no build, no framework): one pending
crop at a time, the operator's pick and the camera's pick beside it, one button (and one
key) per vocabulary class + *unusable* + *skip*. HTTP Basic, one login
(`COLLECTOR_REVIEWER_USER/PASS`), over the overlay. Stats: per booth received / pending /
reviewed; per operator (booth + hash) **agree / disagree / unusable** — disagree = the
reviewer's class is outside the operator's chosen category. That column is the honest-mistake
/ fraud rate.
- **`GET /export/labels.csv`** — reviewed, usable rows: item, booth, crop path, the reviewer's
label, the operator's category + classes, the camera's class + confidence, downgraded, at.
Crops are not packaged: the phase-B trainer runs **on the same host** (its GPU) and reads them
off the volume — `docker-compose.collector.yml` carries the `trainer` seam as a commented
`profiles: [train]` one-off job (next increment).
**Deploy notes.** Bind the published port to the host's **Netbird address** (`COLLECTOR_BIND`),
never `0.0.0.0` on a host with a public interface; Netbird policy: booths → this host:8090 and
nothing else. The host must be onboarded as a Komodo server like the booths. `TAG` is pinned
and promoted with the booths (one sha for all stacks) — fine while the collector stays small;
its own repo the day it needs its own cadence.
@@ -151,6 +151,14 @@ here so it isn't re-litigated.
`komodo/resources.toml` + README) so the control plane is itself reviewable + `komodo/resources.toml` + README) so the control plane is itself reviewable +
version-controlled. version-controlled.
## A non-booth stack (2026-09-06)
The Stack model turned out to fit a service that is *not* a booth: the Car Wash review
collector ([[vision-review-outbox]]) runs on the reviewer's GPU host as its own `[[stack]]`
(`wash-collector`, `server = "art-docker-station"`, `file_paths = ["docker-compose.collector.yml"]`).
Same repo, branch and pinned `TAG` promotion, its own secret references, and — because a stack
names its compose files — nothing booth-side lands on that host and nothing of it on a booth.
## Open / not yet done ## Open / not yet done
- **Per-booth secret generation + rotation flow** — how a new site's unique `EVENT_SIGNING_KEY` - **Per-booth secret generation + rotation flow** — how a new site's unique `EVENT_SIGNING_KEY`
+4 -2
View File
@@ -144,5 +144,7 @@ procurement. (See [[parking-system-architecture]] §10.)
meanings (`session:read` as "works the booth till", `report:read` as "may open the socket") meanings (`session:read` as "works the booth till", `report:read` as "may open the socket")
and a composer at the wrong altitude. Decision + three moves (per-desk till guards, jobs on and a composer at the wrong altitude. Decision + three moves (per-desk till guards, jobs on
top of the grid, a permission-scoped live feed) on [[venue-modules]] §"Permissions matrix"; top of the grid, a permission-scoped live feed) on [[venue-modules]] §"Permissions matrix";
moves built 2026-09-05. Open: default supervisor bundle, re-applying jobs after a module moves built 2026-09-05. The three loose ends closed 2026-09-06: the supervisor bundle
update, signing role edits. already carried `subscription:*` (stale note); roles now remember the jobs they follow and
a grown job is re-applied with one click, never silently; every role edit is signed as a
`config_change`. **Settled** — details on [[venue-modules]] §"Permissions matrix" Status.
+60 -8
View File
@@ -170,6 +170,43 @@ vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on t
and the flagged-category set are **site config** (a minivan-heavy site tunes the noise down). and the flagged-category set are **site config** (a minivan-heavy site tunes the noise down).
- CPU: a second model per frame on the i5-8500 — analyse one frame per vehicle, not every push. - CPU: a second model per frame on the i5-8500 — analyse one frame per vehicle, not every push.
**As built (2026-09-06) — the app plumbing; the model is the open half.** Decisions from the user:
a flagged downgrade is *recorded only* (no reason prompt), and Setup maps the vision vocabulary
onto the site's own categories ("car, sedan, hatchback → Vetura").
- **Vocabulary — lives in code, on purpose (user, 2026-09-06).** `VEHICLE_CLASSES` in
`@parking/shared` (car, sedan, hatchback, suv, minivan, pickup, van, truck, bus, motorcycle) is
the model's contract, not site data: a site cannot invent a class the camera never emits, so
the list is a constant, a new class ships as a release with the model that produces it, and
sites only MAP it (Veture, Makine, Fuoristrade — whatever they call their categories). Stored
mappings hold the ids, so labels can be renamed freely and a retired id is filtered out
silently. The Setup chips show the canonical ids (lowercase monospace), never a translation,
so they read as what they are. The service's `/analyze` `vehicle.body_type` + `confidence`
carry it; the Node client normalises and drops anything outside the list.
- **Record.** `snapshot.ts` writes the read into the same unsigned `device_events` row as the plate
(or a row of its own when the plate was unreadable); `vehicleForIdentity()` resolves it like the
plate (entry over exit, newest first). Never on the ledger by itself.
- **Mapping + threshold.** `carwash_categories.vision_classes` (JSON list) and
`carwash_config.vision_threshold` (default 0.8; signed `config_change carwash.visionThreshold`
when it moves). Migration 0030.
- **Desk.** The ticket lookup returns `vision` + `suggestedCategoryId`; the intake pre-selects it
and shows "Camera saw SUV 91% → SUV" with the snapshot thumbnail; the operator may change it.
- **Flag.** On intake, if the mapped category prices HIGHER than the chosen one for that service
and the read is at or above the threshold → one `anomaly` (`carwash.categoryDowngrade`, both
categories, both prices, operator, snapshotId) and `downgrade_event_id` on the order. Equal,
upgrade, unsure or unmapped reads flag nothing. The order is always created.
- **Model — phase A built (same day).** YOLOX-S (Apache-2.0 ONNX) as a vehicle stage beside the
plate recognizer: car / truck / bus / motorcycle + the vehicle crop, ~250 ms per entry frame on
CPU, weights baked into the vision image. Details and measurements on [[opencv-anpr-service]]
§Vehicle body type. Phase B = the body-type classifier trained on the pilot's own frames —
every wash order is a labelled frame (entry snapshot + the category a person chose), so the
dataset builds itself on park-2. Until then a Vetura/SUV list sees every car as Vetura and no
downgrade fires; van/truck/bus/motorcycle do separate. Reports (discrepancies per operator per
shift) wait for the first real reads.
- **The operator's label is a hypothesis (user, 2026-09-06)** — the training label is a trusted
remote reviewer's. Booth side built: [[vision-review-outbox]] (crop + blurred plate + choice,
queued off the intake path, drained one-way over Netbird, no site identity leaves). The
collector is the open half.
## Car Wash — the pilot module (settled 2026-09-05) ## Car Wash — the pilot module (settled 2026-09-05)
- **Car Wash is the pilot for the registry (settled).** It is built *as* the first module, and - **Car Wash is the pilot for the registry (settled).** It is built *as* the first module, and
@@ -439,9 +476,23 @@ resources — the till already IS that copy. Role *templates stored in the DB*
(they change with the module), roles are data; keep that line. (they change with the module), roles are data; keep that line.
**Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]] **Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]]
§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a §Tills). The three loose ends closed 2026-09-06:
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle - `booth-supervisor` DOES carry `subscription:read/create/update` (plus `tariff:read`,
as it is now); an audit `config_change` on role edits. `validation:read`) — it already did; the note was stale. Decided: a supervisor sells and
edits subscriptions by default.
- **Jobs are remembered and re-appliable.** A role stores the jobs it follows (`role_jobs`:
the chips on at save, plus any bundle fully present). `jobsBehind(jobs, has)` in
`@parking/shared` lists a followed job whose bundle has GROWN past the role (a newer
release added a permission); the roles list shows a "behind <job>" badge with a one-click
"Update to job" (the union; nothing removed), the editor lints it. Deliberately NOT a
runtime union: what a role may do is always the explicit grid, and a software update never
changes it without an admin's click — see the threat model. The first failure of this kind
was the wash operator's empty price list (the settings read needed `site:read`; now
`carwash:read` OR `site:read`, `requireAnyPermission`).
- **Role edits are signed.** Create/update/delete each append one `config_change`
(`setting: role.<id>`, `value`/`prev` = name + sorted permissions + jobs, `operator`); a
no-op resave signs nothing. A role edit is a privilege change and was the one setting an
admin could alter without a trace.
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05) ## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
@@ -522,11 +573,12 @@ control against the unrecorded-wash vector, and it must sit with the person hold
→ Roles); a permission-scoped live feed for module desks (the WS is `report:read` only — → Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
the wash desk polls, 5 s / 15 s). the wash desk polls, 5 s / 15 s).
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is **Follow-ups, closed 2026-09-06** (details on [[shift]] §Tills and [[printer-roles-failover]]):
still a time window over the whole chain, so a booth shift's log shows wash events in that the activity log is per till (`tillOfEvent`, `/api/events?till=`; a feed-permission role
window (money figures are per till; the log is not). A separate wash bucket on the booth's reads its module's events without `event:read`); the booth Z-report carries
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth `chargesByModuleMinor` (a booth-paid wash is out of the ticket bucket, printed
printer until a wash-desk printer role exists. `Lavazh (në biletë)`); the wash till's slips print on a `wash-desk` printer, falling back
to the booth's.
## Review log — issues and ideas from the first hands-on pass (2026-09-05) ## Review log — issues and ideas from the first hands-on pass (2026-09-05)
+7
View File
@@ -29,6 +29,8 @@ Authentication and authorization, kept **fully local** — a direct consequence
`roles` + `role_permissions` tables, composed by an admin from a **code-defined permission grid** `roles` + `role_permissions` tables, composed by an admin from a **code-defined permission grid**
(`@parking/shared` `PERMISSIONS` = `resource:action`, e.g. `tariff:update`, `payment:create`, (`@parking/shared` `PERMISSIONS` = `resource:action`, e.g. `tariff:update`, `payment:create`,
`event:void`). A `preHandler` `requirePermission(...)` per route checks a PERMISSION, not a role `event:void`). A `preHandler` `requirePermission(...)` per route checks a PERMISSION, not a role
(all of the listed; `requireAnyPermission(...)`, 2026-09-06, for a read two jobs share — the
Car Wash price list is the desk's under `carwash:read` and Setup's under `site:read`)
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale. role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
@@ -37,6 +39,11 @@ Authentication and authorization, kept **fully local** — a direct consequence
`bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting `bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting
the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new
wash role kept the old role's rights until logout. wash role kept the old role's rights until logout.
**Role edits are signed (2026-09-06):** every create/update/delete of a role appends a
`config_change` (`role.<id>`, before/after shape, operator) to the ledger, and a role remembers
the manifest JOBS it was composed from (`role_jobs`) so a job that grows in a later release can
be re-applied with one click rather than expanding silently — [[venue-modules]] §Permissions
matrix.
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and - **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
**last user holding admin** — administration can never be locked out of the appliance. **last user holding admin** — administration can never be locked out of the appliance.
+42
View File
@@ -248,3 +248,45 @@ service's `/health` each tick and shows a **"Vision" chip** in the booth footer
([[bom]], [[open-questions]]). ([[bom]], [[open-questions]]).
- Per-camera **opt-in** — ✅ **built**: `config.anpr === true` enables ANPR on a camera (set via the - Per-camera **opt-in** — ✅ **built**: `config.anpr === true` enables ANPR on a camera (set via the
SetupWizard checkbox); ANPR then runs on that camera's entry/exit snapshot. SetupWizard checkbox); ANPR then runs on that camera's entry/exit snapshot.
## Vehicle body type (advisory) — the vehicle stage, phase A (2026-09-06)
`/analyze` populates `vehicle.body_type` + `vehicle.confidence` from the shared vocabulary
(car, sedan, hatchback, suv, minivan, pickup, van, truck, bus, motorcycle). Node records it beside
the plate and the Car Wash desk pre-selects the category the site maps it to; the operator
decides, a confident downgrade is flagged, nothing is gated on it ([[venue-modules]] §Vehicle
category from vision).
**Phase A = YOLOX-S (Megvii, Apache-2.0) as ONNX** on the same ONNX Runtime the plate stage uses
— the licence rule that keeps Ultralytics (AGPL) out. `vision_service/vehicle.py`: pure numpy/cv2
letterbox (pad 114, raw 0–255 BGR — YOLOX's exported graphs are not normalised), stride-grid
decode, class-agnostic NMS, COCO `car/motorcycle/bus/truck` → the vocabulary, and ONE vehicle per
frame: the box holding the plate's centre when a plate was read (the car that was read, not the
one behind), else the largest box. `WithVehicle` in `recognizer.py` wraps whichever plate
recognizer runs (stub included, so the stage is testable without fast-alpr); a failing stage
yields `vehicle: null` and a `vehicle: …` note in `/health.detail` — it never costs the plate
read. Composed `model_version` reads `<plate>+yolox:yolox_s.onnx@640`.
- **Config:** `VISION_VEHICLE_MODEL_PATH` (unset = stage off), `VISION_VEHICLE_INPUT_SIZE` (640),
`VISION_VEHICLE_MIN_CONFIDENCE` (0.4 — the detector's floor; the SITE threshold that decides a
flag lives in Setup → Car wash). The Docker image bakes the weights at `/app/models/yolox_s.onnx`
(best-effort curl at build; no network → stage off) and sets the path, so the air-gapped
appliance never fetches at runtime and no operator-writable path holds a model
([[vision-service-hardening]]). Compose forwards the var; set it EMPTY in the stack env to
switch the stage off. Locally: curl the release file into `apps/vision/models/` (gitignored).
- **Measured on dev (2026-09-06), four real 2560×1440 entry frames from the DS-2CD1047G3H:** three
with a car → `car` at 0.83–0.88, ~240–330 ms each on the dev CPU with 2 intra-op threads; the
empty-lane frame with a person at the camera → no vehicle (correct: a person is not a class we
keep). One frame per entry, so the cost is invisible to the lane.
- **What it cannot do:** SUV vs sedan — COCO has one `car`. For a Vetura/SUV price list every car
maps to Vetura and no downgrade fires; vans, trucks, buses and motorcycles do separate. Phase B
(a body-type classifier on the pilot's own frames — every wash order is a labelled frame) is
what closes that gap; the detector's box is the crop it will classify.
- **Training (user asked, 2026-09-06): YOLOX-S needs none.** It ships trained on COCO and is a
finished detector; its limit is vocabulary, not quality. Phase B is a *different, smaller*
model — a classifier over the detector's crop, not a retrained detector — fine-tuned on a small
Apache-licensed backbone. Data: a few hundred crops per category to start, a couple of
thousand is comfortable, all from the pilot's own lane and camera. Labels are NOT the
operator's picks (untrusted — [[threat-model]]) but a trusted reviewer's, gathered through the
[[vision-review-outbox]]. Expect 85–95 % on frontal gate views once tuned — enough to flag,
never to bill, which is why the flag records and the site threshold exists.
+1
View File
@@ -57,6 +57,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[append-only-event-chain]] — append-only + hash chain + signing = unforgeable log (signing is **software today**; hardware signer pending — see below). - [[append-only-event-chain]] — append-only + hash chain + signing = unforgeable log (signing is **software today**; hardware signer pending — see below).
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain. - [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is. - [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
- [[vision-review-outbox]] — the wash operator's category choice is a hypothesis: the booth queues a plate-blurred vehicle crop + the choice for a trusted remote reviewer over Netbird (one-way, offline-first, no site identity leaves); the verdict = training label + per-operator error/fraud rate.
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]]. - [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug). - [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
+72
View File
@@ -3049,3 +3049,75 @@ design error: the discount ENGINE (validation program rows + `applyValidation()`
`validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the `validation` module is only the merchant's scan screen. Fixed: `dependsOn: ["parking"]`; the
program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void) program routes are plain site:read/site:update; the merchant routes (mine/lookup/apply/void)
stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built). stay module-gated. Tests updated. Recorded on [[venue-modules]] (v1 answers item 4 + As-built).
## [2026-09-06] ingest | Tills follow-ups closed: per-till activity log, wash bucket on the Z, wash-desk printer
The three "known follow-ups" of the Tills decision are built. (1) `tillOfEvent(type, payload)`
in `@parking/shared` — money events by payload `till`, other events by their owning module's
till, everything else booth — is applied by `/api/events?till=` in SQL and passed by the hub
log, the Drawer "today" panel and the booth feed; the events route now admits module-feed
roles (a wash operator's `carwash:read`) and returns only their module's types, the same rule
the live socket uses. (2) `chargesByModuleMinor` on the shift report/summary/signed payload:
module charges on the till's payments by module; the ticket bucket excludes them; printed
`Lavazh (në biletë)` on the booth slip; the wash till's slip prints `Lavazh:` for its own
takings. (3) Printer role `wash-desk`: the wash till's Z-report and vouchers print there with
failover to the booth printer; `printerRoleOf()` is the one reading of the role field so a
desk printer is never mistaken for an entry dispenser; footer label "at wash desk". Updated
[[shift]] §Tills, [[printer-roles-failover]], [[venue-modules]].
## [2026-09-06] ingest | Wash operator job could not load the desk's price list
User built a role from the "Wash operator" chip (carwash:read/create/update/cash) and the desk's
category/service pickers stayed empty. Cause: `GET /api/carwash/settings` was guarded by
`site:read` only — the price list is Setup's data AND the desk's working data. Fixed with a
new `requireAnyPermission(...)` guard (auth.ts): the read opens to `carwash:read` OR
`site:read`; the write stays `site:update`. Regression test in carwash.test.ts.
## [2026-09-06] ingest | Permissions matrix loose ends: jobs remembered + re-appliable, role edits signed
Roles now store the jobs they follow (`role_jobs`, migration 0029); `jobsBehind()` in
`@parking/shared` surfaces a followed job whose bundle grew past the role; the roles list shows a
"behind <job>" badge + "Update to job" (union, nothing removed) and the editor lints it. Not a
runtime union by decision (the grid stays explicit; an update never widens a role without a
click). Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
name + permissions + jobs, operator); a no-op resave signs nothing. The stale "should
booth-supervisor carry subscription:*" note is closed — it already does. Tests: routes/roles.test.ts.
Updated [[venue-modules]] §Permissions matrix status, [[local-jwt-auth]].
## [2026-09-06] ingest | Vision vehicle category — app plumbing built, model pending
Decisions (user): a flagged downgrade is recorded only; Setup maps vision classes onto the site's
categories. Built: `VEHICLE_CLASSES` vocabulary + `VehicleRead` (shared); `/analyze`
`vehicle.body_type`/`confidence` in the service schema and the Node client; the read stored in the
plate's `device_events` row (`vehicleForIdentity`); `carwash_categories.vision_classes`,
`carwash_config.vision_threshold`, four vision columns on orders (migration 0030); Setup chips per
category + threshold; the desk pre-selects the mapped category and shows the read + thumbnail;
a confident, pricier-mapped read with a cheaper choice signs `anomaly carwash.categoryDowngrade`
(both categories/prices, operator, snapshot) — never blocks. No recognizer emits body_type yet.
Tests in carwash.test.ts. Updated [[venue-modules]] (As built), [[opencv-anpr-service]].
## [2026-09-06] ingest | Vision vehicle stage, phase A: YOLOX-S beside the plate recognizer
`vision_service/vehicle.py` (YOLOX ONNX on onnxruntime: letterbox, grid decode, NMS, COCO
car/motorcycle/bus/truck → vocabulary, one vehicle per frame — the box holding the plate, else the
largest) + `WithVehicle` composition over any plate recognizer; `VISION_VEHICLE_MODEL_PATH` (unset =
off), input size, detector floor; Dockerfile bakes yolox_s.onnx (best-effort curl) and sets the
path; compose forwards it (empty = off). Measured on four real dev entry frames: car at 0.83–0.88,
~240–330 ms, empty lane → none. Tests: tests/test_vehicle.py (pure post-processing + composition +
missing-model health). Updated [[opencv-anpr-service]], [[venue-modules]].
## [2026-09-06] ingest | Car Wash review outbox — booth side
User: the operator cannot be fully trusted, so their category choice + the snapshot go to a
trusted remote reviewer over Netbird, with the plate blurred and no site identity. Built the booth
side: vision reads keep the vehicle and plate boxes as frame fractions (service bbox → client →
device_events); `carwash_review_outbox` (0031); `review-outbox.ts` (crop with margin ≤ 640 px,
plate blurred in place, pseudonymous booth id + keyed operator hash, multipart POST with a
per-booth bearer, backoff, permanent rejections, void/expiry abandon, image dropped once sent);
enqueue off the intake path in `createOrder`; `/api/carwash/review/status` + a Setup line; env +
compose. New concept page [[vision-review-outbox]]; [[venue-modules]] As built; index.
## [2026-09-06] ingest | Review collector skeleton — apps/collector + its own Komodo stack
Built `apps/collector` (Fastify + SQLite, shares the contract via @parking/shared): `POST /ingest`
(bearer per booth, X-Booth-Id must match, multipart meta+JPEG validated, idempotent on item id,
crop on the volume), the reviewer's screen served by the process (Basic auth; one button/key per
class + unusable + skip; per-operator agree/disagree/unusable stats), `GET /export/labels.csv`.
Delivery: `apps/collector/Dockerfile` (monorepo context), `docker-compose.collector.yml` (bind to
the overlay address; commented `trainer` profile seam for the GPU), a third build step in
build-images.yml, and a `wash-collector` stack on `art-docker-station` in komodo/resources.toml
(secret refs to fill). Booth payload now carries `operatorCategory.classes`. Tests: app.test.ts.
Updated [[vision-review-outbox]], [[fleet-deployment-komodo]].