Files
parking_solution/apps/server/src/plate-lookup.ts
T
julian 5e1395db18 feat(carwash): advisory vehicle category from the entry camera — mapping, pre-select, downgrade flag
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).

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

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

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 13:37:34 +02:00

111 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
//
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
// session); within a direction, the newest read wins. Returns the plate text only —
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
/** The best advisory plate observed for a session, for display. */
export interface PlateView {
readonly plate: string;
readonly confidence: number | null;
readonly direction: "entry" | "exit" | null;
}
interface ReadDetail {
identity?: string;
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. */
export function plateForIdentity(db: Db, identity: string): PlateView | 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();
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
}
/** Resolve plates for MANY identities in one device_events scan (used by the active-
* sessions list and the feed page, which each carry tens–hundreds of rows). */
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
const want = new Set(identities);
const out = new Map<string, PlateView>();
if (want.size === 0) return out;
// Newest first so the first acceptable read per (identity,direction) is the freshest.
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const byId = new Map<string, ReadDetail[]>();
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
let list = byId.get(d.identity);
if (!list) byId.set(d.identity, (list = []));
list.push(d);
}
for (const [id, reads] of byId) {
const best = pickBest(reads, id);
if (best) out.set(id, best);
}
return out;
}
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
* exit read; otherwise the first (newest) acceptable read wins. */
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
let fallback: ReadDetail | null = null;
for (const d of reads) {
if (d.identity !== identity || !d.plate) continue;
if (d.direction === "entry") return toView(d);
if (!fallback) fallback = d;
}
return fallback ? toView(fallback) : null;
}
function toView(d: ReadDetail): PlateView {
return {
plate: d.plate!.trim().toUpperCase(),
confidence: typeof d.confidence === "number" ? d.confidence : null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
};
}