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
+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 },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
};
@@ -177,6 +178,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
})),
@@ -207,6 +209,7 @@ describe("AnprBridge", () => {
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
plates: [],
lowConfidence: false,
vehicle: null,
modelVersion: "test",
tookMs: 1,
})),
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
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 { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
@@ -560,3 +560,87 @@ describe("a shift's activity log is per till", () => {
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).toEqual({ 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);
});
});
+119 -6
View File
@@ -23,10 +23,16 @@ import {
type CarwashOrderView,
type CarwashSettingsView,
type ChargeLine,
CARWASH_VISION_THRESHOLD_DEFAULT,
isVehicleClass,
reasonPayload,
type VehicleClass,
type VehicleRead,
type Tender,
type TillId,
} from "@parking/shared";
import type { EventLog } from "../../event-log.js";
import { vehicleForIdentity } from "../../plate-lookup.js";
import { effectiveModulesFor } from "../../modules.js";
import type { ChargeProvider, PayStation } from "../../pay-station.js";
import type { ShiftService } from "../../shift-service.js";
@@ -57,11 +63,12 @@ export class CarwashError extends Error {
}
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 }[];
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
/** Where wash money is taken at this site (site-level policy). */
payAt?: unknown;
visionThreshold?: unknown;
}
export interface CreateOrderInput {
@@ -83,6 +90,10 @@ export interface TicketLookup {
enteredAt: string | null;
currency: string | null;
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}$/;
@@ -127,7 +138,7 @@ export class CarwashService {
.where(isNull(carwashCategories.deletedAt))
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
.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
.select()
.from(carwashServices)
@@ -142,7 +153,7 @@ export class CarwashService {
.all()
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
.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. */
@@ -151,6 +162,25 @@ export class CarwashService {
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 booth takes). null when no tariff is published yet. */
#currency(): string | null {
@@ -171,7 +201,7 @@ export class CarwashService {
const now = new Date().toISOString();
const upsertList = (
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,
): string[] => {
if (items === undefined) {
@@ -190,11 +220,19 @@ export class CarwashService {
while (seen.has(id)) id = `${id}-${sort}`;
seen.add(id);
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();
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 {
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);
sort += 1;
@@ -260,6 +298,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();
}
@@ -289,6 +346,10 @@ export class CarwashService {
validationEventId: r.validationEventId,
voidBy: r.voidBy,
voidReason: r.voidReason,
visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null,
visionConfidence: r.visionConfidence,
visionCategoryId: r.visionCategoryId,
downgradeEventId: r.downgradeEventId,
};
}
@@ -335,6 +396,7 @@ export class CarwashService {
lookup(identity: string): TicketLookup {
const id = identity.trim();
const s = this.#pay.lookup(id);
const vision = s.found ? vehicleForIdentity(this.#db, id) : null;
return {
identity: id,
found: s.found,
@@ -344,6 +406,8 @@ export class CarwashService {
enteredAt: s.enteredAt,
currency: s.currency,
orders: this.#ordersFor(id),
vision,
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
};
}
@@ -383,6 +447,51 @@ export class CarwashService {
const currency = s.currency ?? this.#pay.activeCurrency();
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 row: CarwashOrderRow = {
id: randomUUID(),
@@ -408,6 +517,10 @@ export class CarwashService {
voidAt: null,
voidBy: null,
voidReason: null,
visionClass: vision?.bodyType ?? null,
visionConfidence: vision?.confidence ?? null,
visionCategoryId: visionCategory?.id ?? null,
downgradeEventId,
};
this.#db.insert(carwashOrders).values(row).run();
await this.#log.append({
+25
View File
@@ -1,4 +1,5 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
import { isVehicleClass, type VehicleRead } from "@parking/shared";
// 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
@@ -23,6 +24,30 @@ interface ReadDetail {
plate?: string;
confidence?: number;
direction?: string;
bodyType?: string;
bodyConfidence?: number;
snapshotId?: string;
}
/** 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 };
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. */
+17 -8
View File
@@ -167,22 +167,29 @@ async function recognizePlate(
): Promise<void> {
try {
const result = await vision.analyze(shot.bytes, shot.contentType);
if (!result || !result.plate || result.lowConfidence) return; // nothing trustworthy to record
const plate = result.plate.text.trim().toUpperCase();
if (!plate) return;
if (!result) return;
// 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 vehicle = result.vehicle
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence }
: {};
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
if (!plate && !result.vehicle) return; // nothing trustworthy to record
db.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId,
category: "camera",
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: {
identity,
direction,
plate,
confidence: result.plate.confidence,
region: result.plate.region ?? null,
...(plate
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null }
: {}),
...vehicle,
modelVersion: result.modelVersion,
snapshotId,
source: "entry-exit-snapshot",
@@ -190,7 +197,9 @@ async function recognizePlate(
occurredAt: new Date().toISOString(),
})
.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
// booth so it backfills the plate badge in place (no refresh). Advisory; ledger untouched.
deviceEvents.emitPlateRecognized({ identity, plate, direction });
+20 -3
View File
@@ -23,6 +23,8 @@ import type { FastifyBaseLogger } from "fastify";
// transport + contract adapter only.
/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */
import { isVehicleClass, type VehicleClass } from "@parking/shared";
export interface PlateBBox {
readonly x1: number;
readonly y1: number;
@@ -40,12 +42,19 @@ export interface VisionPlate {
readonly region?: string | null;
}
/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for
* Job 2 (vehicle verification) — not yet produced. */
/** The vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
* 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 raw /analyze response shape (the Python contract). */
interface AnalyzeResponse {
readonly plate: VisionPlate | null;
readonly plates: VisionPlate[];
readonly vehicle: unknown | null;
readonly vehicle: { body_type?: string | null; confidence?: number | null } | null;
readonly low_confidence: boolean;
readonly model_version: string;
readonly took_ms: number;
@@ -61,6 +70,8 @@ export interface VisionResult {
/** True when the best plate is below the confidence floor — treat as advisory only
* and fall back to the ticket/manual path. */
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 tookMs: number;
}
@@ -124,10 +135,16 @@ export class VisionClient {
const best = res.plate ?? null;
const lowConfidence =
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)) }
: null;
return {
plate: best,
plates: Array.isArray(res.plates) ? res.plates : [],
lowConfidence,
vehicle,
modelVersion: res.model_version ?? "unknown",
tookMs: typeof res.took_ms === "number" ? res.took_ms : 0,
};
+9 -1
View File
@@ -31,10 +31,18 @@ class PlateResult(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
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)
make: str | None = None
model: str | None = None
+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> {
@@ -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;
+7
View File
@@ -211,6 +211,13 @@
"when": 1788690000000,
"tag": "0029_role_jobs",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1788700000000,
"tag": "0030_carwash_vision",
"breakpoints": true
}
]
}
+13 -1
View File
@@ -1,5 +1,5 @@
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:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
@@ -653,6 +653,9 @@ export const carwashCategories = sqliteTable("carwash_categories", {
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
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")
.notNull()
.default(sql`(current_timestamp)`),
@@ -723,6 +726,13 @@ export const carwashOrders = sqliteTable("carwash_orders", {
voidAt: text("void_at"),
voidBy: text("void_by"),
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
@@ -730,6 +740,8 @@ export const carwashOrders = sqliteTable("carwash_orders", {
export const carwashConfig = sqliteTable("carwash_config", {
id: integer("id").primaryKey(),
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"),
updatedBy: text("updated_by"),
});
+37 -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).
// Post-hoc + advisory (ANPR never gates); the operator voids the duplicate.
"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.refused.closed",
"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.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 — no open session for ticket",
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
@@ -2024,8 +2029,31 @@ export interface ChargeLine {
/** Setup → Car wash: the admin-maintained master data, as read/written by
* 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;
}
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 }[];
/** One entry per priced (category, service) pair. */
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
@@ -2033,6 +2061,8 @@ export interface CarwashSettingsView {
/** 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. */
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). */
@@ -2060,6 +2090,12 @@ export interface CarwashOrderView {
readonly validationEventId: string | null;
readonly voidBy: 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 {
+25
View File
@@ -170,6 +170,31 @@ 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).
- 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.** `VEHICLE_CLASSES` in `@parking/shared` (car, sedan, hatchback, suv, minivan,
pickup, van, truck, bus, motorcycle). 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 — NOT built.** No bundled recognizer produces `body_type` yet, so today the desk shows
nothing and nothing is flagged. Phase A = a COCO detector on Apache-2.0 ONNX weights (car /
truck / bus / motorcycle, plus the vehicle crop); 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. Reports (discrepancies per operator per
shift) wait for the first real reads.
## 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
+9
View File
@@ -248,3 +248,12 @@ service's `/health` each tick and shows a **"Vision" chip** in the booth footer
([[bom]], [[open-questions]]).
- 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.
## Vehicle body type (advisory) — contract only, 2026-09-06
`/analyze` may now populate `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. No bundled recognizer emits
it yet — see [[venue-modules]] §Vehicle category from vision for the model plan (COCO detector
first, body-type classifier on own frames second).
+11
View File
@@ -3080,3 +3080,14 @@ click). Every role create/update/delete appends a `config_change` (`role.<id>`,
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]].