b485e9870b
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small Fastify + SQLite service in the monorepo (shares the payload contract and the class vocabulary via @parking/shared), delivered to art-docker-station by its own stack so nothing booth-side lands there and nothing of it on a booth. - POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row. - /review + /api/*: the reviewer's screen served by the process (Basic auth, one login): one pending crop at a time, operator's pick and camera's pick beside it, one button/key per vocabulary class + unusable + skip; stats per booth and per hashed operator (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's category). - GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host. - Booth payload now carries operatorCategory.classes so the comparison needs no site setup. - Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one secret per booth referenced from both the collector's token list and the booth's own stack (park-2 lines templated, commented, DNS name for the URL). - Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
766 lines
30 KiB
TypeScript
766 lines
30 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import {
|
|
and,
|
|
asc,
|
|
carwashCategories,
|
|
carwashConfig,
|
|
carwashOrders,
|
|
carwashPrices,
|
|
carwashServices,
|
|
desc,
|
|
eq,
|
|
inArray,
|
|
isNull,
|
|
type CarwashOrderRow,
|
|
type Db,
|
|
} from "@parking/db";
|
|
import {
|
|
CARWASH_PAY_AT,
|
|
CARWASH_PAY_AT_DEFAULT,
|
|
CARWASH_PROGRAM_ID,
|
|
type CarWashPayAt,
|
|
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 type { ReviewOutbox } from "./review-outbox.js";
|
|
import { effectiveModulesFor } from "../../modules.js";
|
|
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
|
import type { ShiftService } from "../../shift-service.js";
|
|
import { applyValidation, liveValidations } from "../../validations.js";
|
|
import type { ServerModuleDeps } from "../index.js";
|
|
|
|
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
|
|
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
|
|
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
|
|
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
|
|
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
|
|
// VALIDATION program, applied through the shared applyValidation() when a wash is done
|
|
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
|
|
|
|
/** A refusal the route maps to an HTTP status. */
|
|
/** The till bay money lands on — declared by the module manifest (MODULES). */
|
|
const CARWASH_TILL: TillId = "carwash";
|
|
|
|
export class CarwashError extends Error {
|
|
constructor(
|
|
readonly status: 400 | 404 | 409,
|
|
message: string,
|
|
readonly code?: string,
|
|
) {
|
|
super(message);
|
|
this.name = "CarwashError";
|
|
}
|
|
}
|
|
|
|
export interface SettingsBody {
|
|
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 {
|
|
identity: string;
|
|
categoryId: string;
|
|
serviceId: string;
|
|
/** Optional — the SITE policy decides; a stale client that sends a different value
|
|
* is refused (409 pay_at_policy) rather than silently overridden. */
|
|
payAt?: CarWashPayAt;
|
|
actor: string;
|
|
}
|
|
|
|
export interface TicketLookup {
|
|
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 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}$/;
|
|
|
|
/** Stable slug for a new master-data row: from the name, else a random id. */
|
|
function slugify(name: string): string {
|
|
const s = name
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 40);
|
|
return s || randomUUID();
|
|
}
|
|
|
|
export class CarwashService {
|
|
readonly #db: Db;
|
|
readonly #log: EventLog;
|
|
readonly #pay: PayStation;
|
|
readonly #shift: ShiftService;
|
|
readonly #logger: FastifyBaseLogger;
|
|
readonly #outbox: ReviewOutbox | null;
|
|
|
|
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
|
|
this.#db = deps.db;
|
|
this.#log = deps.eventLog;
|
|
this.#pay = deps.payStation;
|
|
this.#shift = deps.shiftService;
|
|
this.#logger = logger;
|
|
this.#outbox = outbox;
|
|
}
|
|
|
|
#enabled(): boolean {
|
|
return effectiveModulesFor(this.#db).includes("carwash");
|
|
}
|
|
|
|
// --- Settings (master data) -------------------------------------------------
|
|
|
|
settings(): CarwashSettingsView {
|
|
const categories = this.#db
|
|
.select()
|
|
.from(carwashCategories)
|
|
.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, visionClasses: r.visionClasses.filter(isVehicleClass) }));
|
|
const services = this.#db
|
|
.select()
|
|
.from(carwashServices)
|
|
.where(isNull(carwashServices.deletedAt))
|
|
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
|
|
.all()
|
|
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
|
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
|
|
const prices = this.#db
|
|
.select()
|
|
.from(carwashPrices)
|
|
.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(), visionThreshold: this.visionThreshold() };
|
|
}
|
|
|
|
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
|
payAt(): CarWashPayAt {
|
|
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
|
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 {
|
|
try {
|
|
// Any open session's quote carries it; without one, fall back to the tariff table.
|
|
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
|
|
if (row) return row.currency;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
return this.#pay.activeCurrency();
|
|
}
|
|
|
|
/** Full-replacement save of the three lists. Rows missing from the body are
|
|
* soft-deleted (orders already reference names + prices by value, so nothing
|
|
* historical changes). Signs one config_change. */
|
|
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
|
|
const now = new Date().toISOString();
|
|
const upsertList = (
|
|
table: typeof carwashCategories | typeof carwashServices,
|
|
items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined,
|
|
label: string,
|
|
): string[] => {
|
|
if (items === undefined) {
|
|
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
|
|
}
|
|
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
|
|
const keep: string[] = [];
|
|
let sort = 0;
|
|
const seen = new Set<string>();
|
|
for (const it of items) {
|
|
const name = String(it?.name ?? "").trim();
|
|
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
|
|
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
|
|
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
|
|
// Two new items slugging to the same id → disambiguate rather than merge.
|
|
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, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run();
|
|
} else {
|
|
this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).run();
|
|
}
|
|
keep.push(id);
|
|
sort += 1;
|
|
}
|
|
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
|
|
for (const r of live) {
|
|
if (!keep.includes(r.id)) {
|
|
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
|
|
}
|
|
}
|
|
return keep;
|
|
};
|
|
|
|
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
|
|
const serviceIds = upsertList(carwashServices, body.services, "services");
|
|
|
|
if (body.prices !== undefined) {
|
|
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
|
|
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
|
for (const p of body.prices) {
|
|
const categoryId = String(p?.categoryId ?? "");
|
|
const serviceId = String(p?.serviceId ?? "");
|
|
const priceMinor = p?.priceMinor;
|
|
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
|
|
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
|
|
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
|
|
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
|
|
}
|
|
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
|
|
}
|
|
this.#db.delete(carwashPrices).run();
|
|
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
|
|
}
|
|
|
|
await this.#log.append({
|
|
type: "config_change",
|
|
source: "manual",
|
|
identity: "module:carwash",
|
|
payload: {
|
|
setting: "carwash.settings",
|
|
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
|
|
operator: actor,
|
|
},
|
|
});
|
|
|
|
// Where the money is taken — a site policy, signed on its own when it flips (it
|
|
// decides which till the cash lands on and whether the booth barrier or the exit
|
|
// reader releases the car; fraud-relevant, so it is attributed like other config).
|
|
if (body.payAt !== undefined) {
|
|
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
|
|
const prev = this.payAt();
|
|
if (body.payAt !== prev) {
|
|
this.#db
|
|
.insert(carwashConfig)
|
|
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
|
|
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
|
|
.run();
|
|
await this.#log.append({
|
|
type: "config_change",
|
|
source: "manual",
|
|
identity: "module:carwash",
|
|
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
|
|
});
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
|
|
// --- Orders ---------------------------------------------------------------------
|
|
|
|
#view(r: CarwashOrderRow): CarwashOrderView {
|
|
return {
|
|
id: r.id,
|
|
identity: r.identity,
|
|
plate: r.plate,
|
|
categoryId: r.categoryId,
|
|
categoryName: r.categoryName,
|
|
serviceId: r.serviceId,
|
|
serviceName: r.serviceName,
|
|
priceMinor: r.priceMinor,
|
|
currency: r.currency,
|
|
payAt: r.payAt,
|
|
status: r.status,
|
|
createdAt: r.createdAt,
|
|
createdBy: r.createdBy,
|
|
doneAt: r.doneAt,
|
|
doneBy: r.doneBy,
|
|
paidAt: r.paidAt,
|
|
paidBy: r.paidBy,
|
|
tender: (r.tender as Tender | null) ?? null,
|
|
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
|
|
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,
|
|
};
|
|
}
|
|
|
|
#row(id: string): CarwashOrderRow {
|
|
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
|
|
if (!r) throw new CarwashError(404, "order not found");
|
|
return r;
|
|
}
|
|
|
|
/** The desk's queue: every order still needing something, oldest first. */
|
|
openOrders(): CarwashOrderView[] {
|
|
return this.#db
|
|
.select()
|
|
.from(carwashOrders)
|
|
.where(inArray(carwashOrders.status, ["open", "done"]))
|
|
.orderBy(asc(carwashOrders.createdAt))
|
|
.all()
|
|
.map((r) => this.#view(r))
|
|
.filter((o) => !o.closed);
|
|
}
|
|
|
|
/** Recent history (closed included), newest first. */
|
|
recentOrders(limit = 100): CarwashOrderView[] {
|
|
return this.#db
|
|
.select()
|
|
.from(carwashOrders)
|
|
.orderBy(desc(carwashOrders.createdAt))
|
|
.limit(Math.min(Math.max(limit, 1), 500))
|
|
.all()
|
|
.map((r) => this.#view(r));
|
|
}
|
|
|
|
#ordersFor(identity: string): CarwashOrderView[] {
|
|
return this.#db
|
|
.select()
|
|
.from(carwashOrders)
|
|
.where(eq(carwashOrders.identity, identity))
|
|
.orderBy(asc(carwashOrders.createdAt))
|
|
.all()
|
|
.map((r) => this.#view(r));
|
|
}
|
|
|
|
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
|
|
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,
|
|
open: s.open,
|
|
subscription: s.subscription,
|
|
plate: s.plate,
|
|
enteredAt: s.enteredAt,
|
|
currency: s.currency,
|
|
orders: this.#ordersFor(id),
|
|
vision,
|
|
suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null,
|
|
};
|
|
}
|
|
|
|
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
|
|
const identity = input.identity.trim();
|
|
if (!identity) throw new CarwashError(400, "identity (ticket) required");
|
|
|
|
const s = this.#pay.lookup(identity);
|
|
if (!s.found) throw new CarwashError(404, "no session for ticket");
|
|
if (!s.open) throw new CarwashError(409, "session is closed");
|
|
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
|
|
|
|
const category = this.#db
|
|
.select()
|
|
.from(carwashCategories)
|
|
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
|
|
.get();
|
|
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
|
|
const service = this.#db
|
|
.select()
|
|
.from(carwashServices)
|
|
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
|
|
.get();
|
|
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
|
|
const price = this.#db
|
|
.select()
|
|
.from(carwashPrices)
|
|
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
|
|
.get();
|
|
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
|
|
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
|
|
// the policy in force. A client that still sends a different value is stale.
|
|
const payAt = this.payAt();
|
|
if (input.payAt !== undefined && input.payAt !== payAt) {
|
|
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
|
|
}
|
|
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(),
|
|
identity,
|
|
plate: s.plate,
|
|
categoryId: category.id,
|
|
categoryName: category.name,
|
|
serviceId: service.id,
|
|
serviceName: service.name,
|
|
priceMinor: price.priceMinor,
|
|
currency,
|
|
payAt,
|
|
status: "open",
|
|
createdAt: now,
|
|
createdBy: input.actor,
|
|
doneAt: null,
|
|
doneBy: null,
|
|
paidAt: null,
|
|
paidBy: null,
|
|
tender: null,
|
|
paymentEventId: null,
|
|
validationEventId: null,
|
|
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();
|
|
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
|
|
if (vision && this.#outbox?.enabled) {
|
|
void this.#outbox.enqueue(
|
|
{
|
|
orderId: row.id,
|
|
createdAt: now,
|
|
createdBy: input.actor,
|
|
categoryId: category.id,
|
|
categoryName: category.name,
|
|
categoryClasses: category.visionClasses,
|
|
serviceName: service.name,
|
|
visionCategoryId: visionCategory?.id ?? null,
|
|
downgraded: downgradeEventId != null,
|
|
},
|
|
vision,
|
|
);
|
|
}
|
|
await this.#log.append({
|
|
type: "carwash_order",
|
|
source: "manual",
|
|
identity,
|
|
payload: {
|
|
sessionRef: identity,
|
|
orderId: row.id,
|
|
action: "created",
|
|
categoryName: row.categoryName,
|
|
serviceName: row.serviceName,
|
|
priceMinor: row.priceMinor,
|
|
currency,
|
|
payAt: row.payAt,
|
|
operator: input.actor,
|
|
},
|
|
});
|
|
return this.#view(row);
|
|
}
|
|
|
|
/** The wash is finished: apply the site's sponsorship program to the parking session
|
|
* (if one is configured and active), then — for a bay order already paid — settle
|
|
* the parking session so the exit reader opens. */
|
|
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
|
|
const r = this.#row(id);
|
|
if (r.status === "void") throw new CarwashError(409, "order is void");
|
|
if (r.status === "done") throw new CarwashError(409, "order is already done");
|
|
const now = new Date().toISOString();
|
|
|
|
let validationEventId: string | null = null;
|
|
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
|
|
// the order's intake to now (= done) — and the order's frozen price. NOT the time
|
|
// since entry: a car parked for hours before it asks for a wash still pays for those
|
|
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
|
|
// The credit lands at the start of the billed period (that is how timeCredit
|
|
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
|
|
// may differ by an increment. See applyValidation().
|
|
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
|
|
const applied = await applyValidation(this.#db, this.#log, {
|
|
programId: CARWASH_PROGRAM_ID,
|
|
identity: r.identity,
|
|
actor,
|
|
wash: { washMinutes, priceMinor: r.priceMinor },
|
|
});
|
|
if (applied.ok) validationEventId = applied.eventId;
|
|
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
|
|
// A real refusal (session closed, daily cap …) — the wash is still done; the
|
|
// customer simply gets no sponsorship. Keep it visible in the log.
|
|
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
|
|
}
|
|
|
|
this.#db
|
|
.update(carwashOrders)
|
|
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
|
|
.where(eq(carwashOrders.id, id))
|
|
.run();
|
|
await this.#log.append({
|
|
type: "carwash_order",
|
|
source: "manual",
|
|
identity: r.identity,
|
|
payload: {
|
|
sessionRef: r.identity,
|
|
orderId: id,
|
|
action: "done",
|
|
categoryName: r.categoryName,
|
|
serviceName: r.serviceName,
|
|
priceMinor: r.priceMinor,
|
|
currency: r.currency,
|
|
payAt: r.payAt,
|
|
...(validationEventId ? { validationEventId } : {}),
|
|
operator: actor,
|
|
},
|
|
});
|
|
const updated = this.#row(id);
|
|
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
|
|
return this.#view(updated);
|
|
}
|
|
|
|
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
|
|
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
|
|
* carwash_payment on that till; then, if the wash is also done, settles the
|
|
* parking session. */
|
|
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
|
|
const r = this.#row(id);
|
|
if (r.status === "void") throw new CarwashError(409, "order is void");
|
|
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
|
|
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
|
|
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
|
|
this.#shift.requireOpenShift(CARWASH_TILL);
|
|
|
|
const ev = await this.#log.append({
|
|
type: "carwash_payment",
|
|
source: "manual",
|
|
identity: r.identity,
|
|
payload: {
|
|
sessionRef: r.identity,
|
|
orderId: id,
|
|
amountMinor: r.priceMinor,
|
|
currency: r.currency,
|
|
tender,
|
|
till: CARWASH_TILL,
|
|
categoryName: r.categoryName,
|
|
serviceName: r.serviceName,
|
|
operator: actor,
|
|
},
|
|
});
|
|
const now = new Date().toISOString();
|
|
this.#db
|
|
.update(carwashOrders)
|
|
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
|
|
.where(eq(carwashOrders.id, id))
|
|
.run();
|
|
const updated = this.#row(id);
|
|
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
|
|
return this.#view(updated);
|
|
}
|
|
|
|
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
|
|
* the $0 parking payment now — that is what the exit READER checks (a validation
|
|
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
|
|
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
|
|
try {
|
|
const s = this.#pay.lookup(r.identity);
|
|
if (!s.open || s.subscription || s.paidAt != null) return;
|
|
const q = this.#pay.quote(r.identity);
|
|
if (q.amountMinor !== 0) return;
|
|
await this.#pay.pay(r.identity, tender);
|
|
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
|
|
} catch (err) {
|
|
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
|
|
const r = this.#row(id);
|
|
if (r.status === "void") throw new CarwashError(409, "order is already void");
|
|
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
|
|
const now = new Date().toISOString();
|
|
// Take back the sponsorship if it is still live (not consumed by a payment).
|
|
if (r.validationEventId) {
|
|
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
|
|
if (live) {
|
|
await this.#log.append({
|
|
type: "validation",
|
|
source: "manual",
|
|
identity: r.identity,
|
|
payload: {
|
|
sessionRef: r.identity,
|
|
refId: r.validationEventId,
|
|
programId: live.programId,
|
|
programLabel: live.label,
|
|
operator: actor,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
this.#db
|
|
.update(carwashOrders)
|
|
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
|
|
.where(eq(carwashOrders.id, id))
|
|
.run();
|
|
await this.#log.append({
|
|
type: "carwash_order",
|
|
source: "manual",
|
|
identity: r.identity,
|
|
payload: {
|
|
sessionRef: r.identity,
|
|
orderId: id,
|
|
action: "void",
|
|
categoryName: r.categoryName,
|
|
serviceName: r.serviceName,
|
|
priceMinor: r.priceMinor,
|
|
currency: r.currency,
|
|
payAt: r.payAt,
|
|
reason: reason || undefined,
|
|
operator: actor,
|
|
},
|
|
});
|
|
return this.#view(this.#row(id));
|
|
}
|
|
|
|
// --- Booth settlement hook ------------------------------------------------------
|
|
|
|
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
|
|
* calls back after the payment is signed so they are marked paid. Off = no lines. */
|
|
chargeProvider(): ChargeProvider {
|
|
return {
|
|
lines: (identity) => {
|
|
if (!this.#enabled()) return [];
|
|
return this.#db
|
|
.select()
|
|
.from(carwashOrders)
|
|
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
|
|
.all()
|
|
.filter((r) => r.status !== "void")
|
|
.map((r) => ({
|
|
module: "carwash" as const,
|
|
ref: r.id,
|
|
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
|
|
amountMinor: r.priceMinor,
|
|
}));
|
|
},
|
|
onPaid: async (_identity, lines, payment) => {
|
|
const now = new Date().toISOString();
|
|
for (const l of lines) {
|
|
if (l.module !== "carwash") continue;
|
|
this.#db
|
|
.update(carwashOrders)
|
|
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
|
|
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
|
|
.run();
|
|
}
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Type guard for the void body etc. */
|
|
export function isPayAt(v: unknown): v is CarWashPayAt {
|
|
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
|
|
}
|