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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user