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
This commit is contained in:
2026-09-06 13:37:34 +02:00
parent 50c18405b6
commit 5e1395db18
19 changed files with 511 additions and 32 deletions
+19
View File
@@ -67,6 +67,18 @@ export const en: Catalog = {
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: {
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.",
@@ -124,6 +136,12 @@ export const en: Catalog = {
sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
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",
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: {
available: "Update available",
@@ -385,6 +403,7 @@ export const en: Catalog = {
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
"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}}",
"carwash.categoryDowngrade": "Wash category downgraded — camera saw {{visionClass}} ({{visionCategory}}), operator {{operator}} chose {{chosenCategory}}",
"exit.refused.closed": "Exit refused — session already closed",
"exit.refused.noSession": "Exit refused — unknown ticket",
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
+18
View File
@@ -70,6 +70,18 @@ export const sq = {
carwash: "Lavazh",
},
},
vehicleClass: {
car: "veturë",
sedan: "sedan",
hatchback: "hatchback",
suv: "SUV",
minivan: "minivan",
pickup: "pikap",
van: "furgon",
truck: "kamion",
bus: "autobus",
motorcycle: "motor",
},
wash: {
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.",
@@ -127,6 +139,11 @@ export const sq = {
sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh",
visionSaw: "Kamera pa",
visionUnmapped: "pa kategori të lidhur",
visionClasses: "Klasat e kamerës",
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: {
available: "Përditësim i disponueshëm",
@@ -389,6 +406,7 @@ export const sq = {
"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.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.noSession": "Dalja u refuzua — biletë e panjohur",
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
+50 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
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 { StationForm, defaultProgram } from "../../ValidationSetup.js";
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
@@ -10,7 +10,7 @@ import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } f
// validation program (id "carwash"), composed with the same editor the merchant
// 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 toMinor = (s: string): number | null => {
@@ -25,18 +25,32 @@ function ListEditor({
items,
onChange,
addLabel,
visionMap,
}: {
title: string;
items: Item[];
onChange: (items: Item[]) => void;
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). */
visionMap?: boolean;
}) {
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 (
<div className="grid gap-1.5">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
{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
className="input flex-1"
value={it.name}
@@ -55,6 +69,20 @@ function ListEditor({
✕
</button>
</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">{t("wash.visionClasses")}</span>
{VEHICLE_CLASSES.map((cls) => {
const on = (it.visionClasses ?? []).includes(cls);
return (
<button key={cls} type="button" className={`btn btn-sm ${on ? "btn-primary" : "btn-ghost"}`} onClick={() => toggleClass(i, cls)}>
{t(`vehicleClass.${cls}`)}
</button>
);
})}
</div>
)}
</div>
))}
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
+ {addLabel}
@@ -73,6 +101,8 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const [prices, setPrices] = useState<Record<string, string>>({});
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
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 [program, setProgram] = useState<ValidationProgramView | null>(null);
@@ -80,12 +110,13 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
fetchCarwashSettings()
.then((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 })));
const p: Record<string, string> = {};
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setPayAt(s.payAt);
setThreshold(String(Math.round(s.visionThreshold * 100)));
})
.catch((e) => setMsg((e as Error).message));
fetchValidationPrograms()
@@ -103,7 +134,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
setMsg(null);
try {
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 })),
};
// New rows have no id until the server assigns one, and the price matrix is keyed
@@ -133,15 +164,18 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
}),
);
const thr = Number(threshold);
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 })),
prices: priceRows,
payAt,
...(Number.isFinite(thr) && thr >= 0 && thr <= 100 ? { visionThreshold: thr / 100 } : {}),
});
setSettings(saved);
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 })));
const p: Record<string, string> = {};
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
@@ -158,7 +192,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-2xl p-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")} />
<div>
@@ -215,6 +249,14 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
</div>
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
</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>
{canEdit && (
<div className="flex items-center gap-3">
+21 -1
View File
@@ -73,7 +73,10 @@ export function WashDesk() {
setMsg(null);
if (!ticket.trim()) return;
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) {
setMsg((err as Error).message);
}
@@ -170,6 +173,23 @@ export function WashDesk() {
<span className="text-term-muted">{t("wash.plate")}</span>
<span className="font-mono">{lookup.plate ?? "—"}</span>
</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 && (
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.enteredAt")}</span>
+8 -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";
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
@@ -15,14 +15,20 @@ export interface CarwashTicketLookup {
enteredAt: string | null;
currency: string | null;
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 {
categories?: { id?: string; name: string; active?: boolean }[];
categories?: { id?: string; name: string; active?: boolean; visionClasses?: VehicleClass[] }[];
services?: { id?: string; name: string; active?: boolean }[];
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
payAt?: CarWashPayAt;
/** Confidence floor (0–1) for a vision class to flag a category downgrade. */
visionThreshold?: number;
}
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {