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:
@@ -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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,10 +23,16 @@ 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 { 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 +63,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 +90,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}$/;
|
||||||
@@ -127,7 +138,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 +153,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 +162,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 +201,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 +220,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 +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();
|
return this.settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +346,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 +396,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 +406,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 +447,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,6 +517,10 @@ 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();
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
|
|||||||
@@ -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 { 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,30 @@ interface ReadDetail {
|
|||||||
plate?: string;
|
plate?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
direction?: string;
|
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. */
|
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||||||
|
|||||||
@@ -167,22 +167,29 @@ 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();
|
// The vehicle's body type (advisory; the wash desk's category suggestion — see
|
||||||
if (!plate) return;
|
// 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)
|
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 }
|
||||||
region: result.plate.region ?? null,
|
: {}),
|
||||||
|
...vehicle,
|
||||||
modelVersion: result.modelVersion,
|
modelVersion: result.modelVersion,
|
||||||
snapshotId,
|
snapshotId,
|
||||||
source: "entry-exit-snapshot",
|
source: "entry-exit-snapshot",
|
||||||
@@ -190,7 +197,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 });
|
||||||
|
|||||||
@@ -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,19 @@ 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 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 } | 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 +70,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 +135,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)) }
|
||||||
|
: 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,10 +31,18 @@ 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)
|
||||||
make: str | None = None
|
make: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 @@ 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",
|
||||||
|
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",
|
||||||
@@ -385,6 +403,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)",
|
||||||
|
|||||||
@@ -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,11 @@ 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",
|
||||||
|
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",
|
||||||
@@ -389,6 +406,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)",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { 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
|
// 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,32 @@ 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). */
|
||||||
|
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 +69,20 @@ 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">{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 }])}>
|
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||||
+ {addLabel}
|
+ {addLabel}
|
||||||
@@ -73,6 +101,8 @@ 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);
|
||||||
|
|
||||||
@@ -80,12 +110,13 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
|||||||
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));
|
||||||
fetchValidationPrograms()
|
fetchValidationPrograms()
|
||||||
@@ -103,7 +134,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 +164,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 +192,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 +249,14 @@ 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>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,20 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
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;
|
||||||
@@ -211,6 +211,13 @@
|
|||||||
"when": 1788690000000,
|
"when": 1788690000000,
|
||||||
"tag": "0029_role_jobs",
|
"tag": "0029_role_jobs",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788700000000,
|
||||||
|
"tag": "0030_carwash_vision",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -653,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)`),
|
||||||
@@ -723,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
|
||||||
@@ -730,6 +740,8 @@ 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"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)",
|
||||||
@@ -2024,8 +2029,31 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
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 }[];
|
||||||
@@ -2033,6 +2061,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). */
|
||||||
@@ -2060,6 +2090,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 {
|
||||||
|
|||||||
@@ -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).
|
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.** `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 — 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
|
||||||
|
|||||||
@@ -248,3 +248,12 @@ 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) — 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
@@ -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
|
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.
|
booth-supervisor carry subscription:*" note is closed — it already does. Tests: routes/roles.test.ts.
|
||||||
Updated [[venue-modules]] §Permissions matrix status, [[local-jwt-auth]].
|
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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user