e67f0ccef0
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
76 lines
3.1 KiB
TypeScript
76 lines
3.1 KiB
TypeScript
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender, VehicleClass, VehicleRead } from "@parking/shared";
|
||
import { apiFetch } from "../../api.js";
|
||
|
||
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
|
||
// client) never learns about wash endpoints. Shapes come from @parking/shared.
|
||
|
||
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
|
||
|
||
export interface CarwashTicketLookup {
|
||
identity: string;
|
||
found: boolean;
|
||
open: boolean;
|
||
subscription: boolean;
|
||
plate: string | null;
|
||
enteredAt: string | null;
|
||
currency: string | null;
|
||
orders: CarwashOrderView[];
|
||
/** What the camera saw at entry (advisory) and the category the site mapping
|
||
* suggests — pre-selected on the desk; the operator may change it. */
|
||
vision: VehicleRead | null;
|
||
suggestedCategoryId: string | null;
|
||
}
|
||
|
||
export interface CarwashSettingsBody {
|
||
categories?: { id?: string; name: string; active?: boolean; visionClasses?: VehicleClass[] }[];
|
||
services?: { id?: string; name: string; active?: boolean }[];
|
||
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
|
||
payAt?: CarWashPayAt;
|
||
/** Confidence floor (0–1) for a vision class to flag a category downgrade. */
|
||
visionThreshold?: number;
|
||
}
|
||
|
||
/** The review outbox's health (Setup → Car wash). */
|
||
export interface CarwashReviewStatus {
|
||
enabled: boolean;
|
||
boothId: string | null;
|
||
queued: number;
|
||
sent: number;
|
||
failed: number;
|
||
lastSentAt: string | null;
|
||
lastError: string | null;
|
||
}
|
||
export function fetchCarwashReviewStatus(): Promise<CarwashReviewStatus> {
|
||
return apiFetch("/api/carwash/review/status");
|
||
}
|
||
|
||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||
return apiFetch("/api/carwash/settings");
|
||
}
|
||
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
|
||
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
|
||
}
|
||
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
|
||
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
|
||
}
|
||
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
|
||
return apiFetch(`/api/carwash/orders?scope=${scope}`);
|
||
}
|
||
export function createCarwashOrder(body: {
|
||
identity: string;
|
||
categoryId: string;
|
||
serviceId: string;
|
||
}): Promise<CarwashOrderView> {
|
||
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
|
||
}
|
||
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
|
||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
|
||
}
|
||
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
|
||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
|
||
}
|
||
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
|
||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
|
||
}
|