Compare commits
27 Commits
v0.1.6
...
dbbb051ebd
| Author | SHA1 | Date | |
|---|---|---|---|
| dbbb051ebd | |||
| 3e57af5abc | |||
| ef55d1c6a9 | |||
| 0411b71c2d | |||
| b485e9870b | |||
| ec44547122 | |||
| e67f0ccef0 | |||
| 78ca58d264 | |||
| 20a3cb3e80 | |||
| 5e1395db18 | |||
| 50c18405b6 | |||
| e14e31a840 | |||
| ea304bbfd1 | |||
| 2aa1045ddc | |||
| acde3bba5b | |||
| 9a13528611 | |||
| 6f88026d3e | |||
| c481c1e788 | |||
| 3a7c3fae11 | |||
| 55d6242c7d | |||
| a9ccf9e20c | |||
| 23d6379be8 | |||
| db9c3e0e31 | |||
| d86bffa500 | |||
| 9c05f86c86 | |||
| 54e691a4c9 | |||
| 52862db8ad |
@@ -1,6 +1,6 @@
|
|||||||
name: Build & push images
|
name: Build & push images
|
||||||
|
|
||||||
# Build the SERVER (API + SPA) and VISION (ANPR) container images and push them to the
|
# Build the SERVER (API + SPA), COLLECTOR (wash review) and VISION (ANPR) container images and push them to the
|
||||||
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
# house Gitea registry, tagged by BRANCH + short SHA (branch-aware: dev→:dev, stage→:stage,
|
||||||
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
# main→:main). Separate from ci.yml (checks-only) and release.yml (tag-only desktop bundle).
|
||||||
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
# Mirrors the house pattern (cf. trm/processor build.yml). See
|
||||||
@@ -13,6 +13,7 @@ on:
|
|||||||
- 'apps/server/**'
|
- 'apps/server/**'
|
||||||
- 'apps/web/**'
|
- 'apps/web/**'
|
||||||
- 'apps/vision/**'
|
- 'apps/vision/**'
|
||||||
|
- 'apps/collector/**'
|
||||||
- 'packages/**'
|
- 'packages/**'
|
||||||
- 'package.json'
|
- 'package.json'
|
||||||
- 'pnpm-lock.yaml'
|
- 'pnpm-lock.yaml'
|
||||||
@@ -100,6 +101,18 @@ jobs:
|
|||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-server:buildcache,mode=max
|
||||||
|
|
||||||
|
- name: Build & push COLLECTOR (wash review)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/collector/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}
|
||||||
|
${{ env.REGISTRY }}/parking-collector:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/parking-collector:buildcache,mode=max
|
||||||
|
|
||||||
- name: Build & push VISION (ANPR)
|
- name: Build & push VISION (ANPR)
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -129,8 +129,23 @@ jobs:
|
|||||||
# The Tauri updater fetches a manifest describing the newest version, its
|
# The Tauri updater fetches a manifest describing the newest version, its
|
||||||
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
# notes, and per-target {signature, url}. The URL points at the MIRROR
|
||||||
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
# repo (mca/public_releases) — that's the unauthenticated endpoint field
|
||||||
# appliances actually reach; see the workflow header for why. Adjust the
|
# appliances actually reach; see the workflow header for why.
|
||||||
# platform keys you actually ship.
|
#
|
||||||
|
# ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update
|
||||||
|
# v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up
|
||||||
|
# `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage,
|
||||||
|
# from the running app's detected bundle type) and only then the bare
|
||||||
|
# `linux-x86_64`. The booths run the .deb, and the manifest used to
|
||||||
|
# carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the
|
||||||
|
# "update", downloaded the AppImage, verified its signature fine, then
|
||||||
|
# handed the bytes to install_deb(), which checks they're a .deb
|
||||||
|
# (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after
|
||||||
|
# the download, before any relaunch, with the error swallowed client-
|
||||||
|
# side until v0.1.6. Now each installer gets its own signed asset; the
|
||||||
|
# bare key stays for an AppImage install. .deb/.rpm updates run
|
||||||
|
# `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password
|
||||||
|
# prompt — intended: updating a root-installed package IS an admin
|
||||||
|
# action on this box (see wiki/decisions/desktop-shell-tauri.md).
|
||||||
env:
|
env:
|
||||||
SERVER_URL: ${{ github.server_url }}
|
SERVER_URL: ${{ github.server_url }}
|
||||||
MIRROR_REPO: mca/public_releases
|
MIRROR_REPO: mca/public_releases
|
||||||
@@ -138,22 +153,41 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
|
ASSET_BASE="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest"
|
||||||
SIG=$(cat "dist/${APPIMAGE}.sig")
|
cat > /tmp/latest.js <<'JS'
|
||||||
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
|
const fs = require("fs");
|
||||||
cat > dist/latest.json <<JSON
|
const [version, tag, base] = process.argv.slice(2);
|
||||||
{
|
const files = fs.readdirSync("dist");
|
||||||
"version": "${VERSION}",
|
const pick = (ext) => files.find((f) => f.endsWith(ext));
|
||||||
"notes": "Parking System ${TAG}",
|
const entry = (f) => ({
|
||||||
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(),
|
||||||
"platforms": {
|
url: `${base}/${f}`,
|
||||||
"linux-x86_64": {
|
});
|
||||||
"signature": "${SIG}",
|
const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage");
|
||||||
"url": "${ASSET_URL}"
|
if (!deb || !appimage) {
|
||||||
}
|
console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`);
|
||||||
}
|
process.exit(1);
|
||||||
}
|
}
|
||||||
JSON
|
const platforms = {
|
||||||
|
"linux-x86_64-deb": entry(deb),
|
||||||
|
...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}),
|
||||||
|
"linux-x86_64": entry(appimage),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
"dist/latest.json",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version,
|
||||||
|
notes: `Parking System ${tag}`,
|
||||||
|
pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
||||||
|
platforms,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
) + "\n",
|
||||||
|
);
|
||||||
|
JS
|
||||||
|
node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}"
|
||||||
echo "latest.json:"; cat dist/latest.json
|
echo "latest.json:"; cat dist/latest.json
|
||||||
|
|
||||||
- name: Create release + upload assets (Gitea API)
|
- name: Create release + upload assets (Gitea API)
|
||||||
|
|||||||
@@ -28,3 +28,8 @@ dist/
|
|||||||
graphify-out/
|
graphify-out/
|
||||||
parking.sqlite*.bak-*
|
parking.sqlite*.bak-*
|
||||||
questions.txt
|
questions.txt
|
||||||
|
|
||||||
|
# session planning files (planning-with-files skill)
|
||||||
|
task_plan.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Car Wash review collector (wiki/concepts/vision-review-outbox.md). Runs on the
|
||||||
|
# reviewer's host (art-docker-station), reachable by the booths ONLY over the Netbird
|
||||||
|
# overlay. Deployed by its own Komodo stack (komodo/resources.toml, "wash-collector").
|
||||||
|
|
||||||
|
# COLLECTOR_HOST=0.0.0.0 # in Docker the compose file binds the published port to the overlay IP
|
||||||
|
# COLLECTOR_PORT=8090
|
||||||
|
# COLLECTOR_DATA_DIR=/data # collector.sqlite + crops/<booth>/<item>.jpg
|
||||||
|
|
||||||
|
# One bearer token per booth: "<boothId>:<token>" pairs, comma- or newline-separated. The
|
||||||
|
# booth id is the pseudonymous CARWASH_REVIEW_BOOTH_ID that booth was deployed with — never
|
||||||
|
# a site name. Generate tokens with: openssl rand -hex 32
|
||||||
|
COLLECTOR_BOOTH_TOKENS=booth-7:REPLACE,booth-9:REPLACE
|
||||||
|
|
||||||
|
# The reviewer's login for the review screen and the export (HTTP Basic over the overlay).
|
||||||
|
COLLECTOR_REVIEWER_USER=reviewer
|
||||||
|
COLLECTOR_REVIEWER_PASS=REPLACE
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# parking-collector — the Car Wash review collector (wiki/concepts/vision-review-outbox.md).
|
||||||
|
# Built from the monorepo root (context: .) like the server image, so it shares the
|
||||||
|
# lockfile and @parking/shared. Runs on the REVIEWER's host (not a booth), delivered by
|
||||||
|
# its own Komodo stack (docker-compose.collector.yml). Data on /data: collector.sqlite +
|
||||||
|
# crops/<booth>/<item>.jpg — the trainer on the same host reads the crops off that volume.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++ # node-gyp for better-sqlite3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/server/package.json apps/server/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/vision/package.json apps/vision/
|
||||||
|
COPY apps/collector/package.json apps/collector/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
COPY packages/devices/package.json packages/devices/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm fetch
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
ENV CI=true
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --offline
|
||||||
|
RUN pnpm turbo run build --filter=@parking/collector
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm --filter=@parking/collector --legacy deploy --prod /deploy
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ARG BUILD_VERSION=""
|
||||||
|
ENV BUILD_VERSION=$BUILD_VERSION
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN apk add --no-cache libstdc++ wget # better-sqlite3 native runtime; wget for the healthcheck
|
||||||
|
RUN addgroup -S app && adduser -S -G app app
|
||||||
|
COPY --from=build --chown=app:app /deploy ./
|
||||||
|
ENV COLLECTOR_DATA_DIR=/data
|
||||||
|
ENV COLLECTOR_HOST=0.0.0.0
|
||||||
|
ENV COLLECTOR_PORT=8090
|
||||||
|
RUN mkdir -p /data && chown app:app /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
USER app
|
||||||
|
EXPOSE 8090
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- "http://localhost:${COLLECTOR_PORT:-8090}/health" >/dev/null 2>&1 || exit 1
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@parking/collector",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Car Wash review collector: receives plate-blurred vehicle crops + the operator's category choice from booths over the private overlay, serves the reviewer's screen, exports labels for training. See wiki/concepts/vision-review-outbox.md.",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"dev": "tsx watch --env-file-if-exists=.env src/index.ts",
|
||||||
|
"start": "node --env-file-if-exists=.env dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/multipart": "^9.2.1",
|
||||||
|
"@parking/shared": "workspace:*",
|
||||||
|
"better-sqlite3": "12.10.1",
|
||||||
|
"fastify": "5.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "7.6.13",
|
||||||
|
"@types/node": "25.9.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { buildCollector, type CollectorApp } from "./app.js";
|
||||||
|
import { parseBoothTokens } from "./config.js";
|
||||||
|
|
||||||
|
// The collector: one ingest surface (bearer per booth, idempotent), one review surface
|
||||||
|
// (Basic), one export. Exercised over app.inject with a hand-built multipart body.
|
||||||
|
|
||||||
|
let app: CollectorApp;
|
||||||
|
let dir: string;
|
||||||
|
const TOKENS = new Map([["booth-7", "0123456789abcdef0123456789abcdef"], ["booth-9", "fedcba9876543210fedcba9876543210"]]);
|
||||||
|
const REVIEWER = { user: "julian", pass: "review-pass-123" };
|
||||||
|
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
|
||||||
|
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: TOKENS, reviewer: REVIEWER }, { dbFile: ":memory:" });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A minimal JPEG-looking blob (SOI marker + padding) — the collector checks the magic only. */
|
||||||
|
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(200, 1)]);
|
||||||
|
|
||||||
|
function meta(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
v: 1, booth: "booth-7", item: "item-1", order: "o-1", at: "2026-09-06T10:00:00.000Z", operator: "ab12cd34ef56ab12",
|
||||||
|
operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan", "hatchback"] }, service: "Standard",
|
||||||
|
vision: { class: "suv", confidence: 0.91, categoryId: "suv" }, downgraded: true,
|
||||||
|
image: { width: 320, height: 200, plateBlurred: true },
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function multipart(fields: Record<string, string>, file: Buffer | null): { body: Buffer; type: string } {
|
||||||
|
const b = "----collector-test";
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||||||
|
if (file) parts.push(Buffer.from(`--${b}\r\nContent-Disposition: form-data; name="image"; filename="x.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`), file, Buffer.from("\r\n"));
|
||||||
|
parts.push(Buffer.from(`--${b}--\r\n`));
|
||||||
|
return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${b}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingest(m: Record<string, unknown>, token = TOKENS.get("booth-7")!, file: Buffer | null = JPEG, extra: Record<string, string> = {}) {
|
||||||
|
const { body, type } = multipart({ meta: JSON.stringify(m) }, file);
|
||||||
|
return app.inject({ method: "POST", url: "/ingest", headers: { authorization: `Bearer ${token}`, "content-type": type, ...extra }, payload: body });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ingest", () => {
|
||||||
|
it("stores the crop and the decision under the token's booth; retries are idempotent", async () => {
|
||||||
|
const r = await ingest(meta());
|
||||||
|
expect(r.statusCode).toBe(201);
|
||||||
|
const row = app.collectorDb.get("item-1")!;
|
||||||
|
expect(row).toMatchObject({ booth: "booth-7", operatorCategoryName: "Vetura", visionClass: "suv", downgraded: 1, plateBlurred: 1, imagePath: "crops/booth-7/item-1.jpg" });
|
||||||
|
expect(JSON.parse(row.operatorClasses)).toEqual(["car", "sedan", "hatchback"]);
|
||||||
|
const again = await ingest(meta());
|
||||||
|
expect(again.statusCode).toBe(200);
|
||||||
|
expect(again.json()).toEqual({ ok: true, duplicate: true });
|
||||||
|
expect((await app.inject({ method: "GET", url: "/health" })).json()).toMatchObject({ ok: true, booths: 1, pending: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a bad token, a booth mismatch, a non-JPEG, and malformed meta", async () => {
|
||||||
|
expect((await ingest(meta(), "nope-nope-nope-nope-nope")).statusCode).toBe(401);
|
||||||
|
expect((await ingest(meta({ booth: "booth-9" }))).statusCode).toBe(422); // token is booth-7's
|
||||||
|
expect((await ingest(meta(), TOKENS.get("booth-7")!, JPEG, { "x-booth-id": "booth-9" })).statusCode).toBe(403);
|
||||||
|
expect((await ingest(meta(), TOKENS.get("booth-7")!, Buffer.alloc(300, 7))).statusCode).toBe(415);
|
||||||
|
expect((await ingest(meta(), TOKENS.get("booth-7")!, null)).statusCode).toBe(400);
|
||||||
|
expect((await ingest(meta({ vision: { class: "spaceship", confidence: 0.5, categoryId: null } }))).statusCode).toBe(422);
|
||||||
|
expect((await ingest(meta({ item: "../../etc/passwd" }))).statusCode).toBe(422);
|
||||||
|
expect((await ingest(meta({ v: 2 }))).statusCode).toBe(422);
|
||||||
|
expect(app.collectorDb.stats().booths).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("review + export", () => {
|
||||||
|
it("the reviewer lists pending items, sees the crop, labels it; stats compare the label with the operator's category; the export lists usable labels only", async () => {
|
||||||
|
await ingest(meta());
|
||||||
|
await ingest(meta({ item: "item-2", operator: "ab12cd34ef56ab12", vision: { class: "car", confidence: 0.8, categoryId: "car" }, downgraded: false }));
|
||||||
|
await ingest(meta({ item: "item-3", booth: "booth-9", operator: "9999999999999999" }), TOKENS.get("booth-9")!);
|
||||||
|
|
||||||
|
// No login → 401 with a challenge; nothing without a configured reviewer is tested in config.
|
||||||
|
const anon = await app.inject({ method: "GET", url: "/api/items" });
|
||||||
|
expect(anon.statusCode).toBe(401);
|
||||||
|
expect(anon.headers["www-authenticate"]).toContain("Basic");
|
||||||
|
expect((await app.inject({ method: "GET", url: "/review", headers: { authorization: basic } })).headers["content-type"]).toContain("text/html");
|
||||||
|
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/items?status=pending", headers: { authorization: basic } })).json();
|
||||||
|
expect(list.items.map((i: { id: string }) => i.id)).toEqual(["item-1", "item-2", "item-3"]);
|
||||||
|
expect(list.items[0].imagePath).toBeUndefined();
|
||||||
|
const img = await app.inject({ method: "GET", url: "/api/items/item-1/image", headers: { authorization: basic } });
|
||||||
|
expect(img.statusCode).toBe(200);
|
||||||
|
expect(img.headers["content-type"]).toBe("image/jpeg");
|
||||||
|
expect(img.rawPayload.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
||||||
|
|
||||||
|
// item-1: operator said Vetura (car/sedan/hatchback), reviewer says suv → disagree.
|
||||||
|
// item-2: reviewer says sedan → inside Vetura → agree. item-3: unusable.
|
||||||
|
const post = (id: string, label: string) =>
|
||||||
|
app.inject({ method: "POST", url: `/api/items/${id}/review`, headers: { authorization: basic, "content-type": "application/json" }, payload: { label } });
|
||||||
|
expect((await post("item-1", "suv")).json()).toMatchObject({ reviewLabel: "suv", reviewer: "julian" });
|
||||||
|
expect((await post("item-2", "sedan")).statusCode).toBe(200);
|
||||||
|
expect((await post("item-3", "unusable")).statusCode).toBe(200);
|
||||||
|
expect((await post("item-3", "spaceship")).statusCode).toBe(400);
|
||||||
|
expect((await post("nope", "suv")).statusCode).toBe(404);
|
||||||
|
|
||||||
|
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
|
||||||
|
expect(stats.booths).toEqual([
|
||||||
|
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
|
||||||
|
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
|
||||||
|
]);
|
||||||
|
expect(stats.operators).toEqual([
|
||||||
|
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
|
||||||
|
{ booth: "booth-9", operatorRef: "9999999999999999", reviewed: 1, agree: 0, disagree: 0, unusable: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
|
||||||
|
expect(csv.statusCode).toBe(200);
|
||||||
|
const lines = csv.body.trim().split("\n");
|
||||||
|
expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
|
||||||
|
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
|
||||||
|
expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
|
||||||
|
|
||||||
|
// An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and
|
||||||
|
// never counted in any operator's agreement.
|
||||||
|
const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } });
|
||||||
|
expect(entry.statusCode).toBe(201);
|
||||||
|
expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422);
|
||||||
|
expect((await post("entry-1", "suv")).statusCode).toBe(200);
|
||||||
|
const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
|
||||||
|
expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 });
|
||||||
|
expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 });
|
||||||
|
const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
|
||||||
|
expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"');
|
||||||
|
|
||||||
|
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
|
||||||
|
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
|
||||||
|
await post("item-4", "car");
|
||||||
|
const csv2 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
|
||||||
|
expect(csv2).toContain(`"'=HYPERLINK(""http://evil"")"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config", () => {
|
||||||
|
it("parses booth:token pairs and refuses short tokens", () => {
|
||||||
|
expect([...parseBoothTokens("a:0123456789abcdef, b:fedcba9876543210\nc:0000000000000000").keys()]).toEqual(["a", "b", "c"]);
|
||||||
|
expect(() => parseBoothTokens("a:short")).toThrow(/too short/);
|
||||||
|
expect(() => parseBoothTokens("nocolon")).toThrow(/bad pair/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
|
import multipart from "@fastify/multipart";
|
||||||
|
import { isVehicleClass } from "@parking/shared";
|
||||||
|
import type { CollectorConfig } from "./config.js";
|
||||||
|
import { CollectorDb, type ItemRow, type ReviewVerdict } from "./db.js";
|
||||||
|
import { reviewPage } from "./review-page.js";
|
||||||
|
|
||||||
|
// The collector — the far end of the booth's review outbox
|
||||||
|
// (wiki/concepts/vision-review-outbox.md). Three surfaces and nothing else:
|
||||||
|
// POST /ingest one package from one booth (bearer token per booth; idempotent)
|
||||||
|
// /review + /api/* the reviewer's screen (HTTP Basic, one login)
|
||||||
|
// GET /export/labels.csv the training set: reviewed, usable rows (crops sit beside it on
|
||||||
|
// the volume, so the trainer on this host reads them directly)
|
||||||
|
// It deliberately has no fleet features and no path back into a booth.
|
||||||
|
|
||||||
|
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
|
||||||
|
interface IngestMeta {
|
||||||
|
v: number;
|
||||||
|
/** "wash" (default when absent) = a desk decision; "entry" = a sampled entry read with
|
||||||
|
* no order and no operator — crop + the camera's class only. */
|
||||||
|
kind?: "wash" | "entry";
|
||||||
|
booth: string;
|
||||||
|
item: string;
|
||||||
|
order?: string;
|
||||||
|
at: string;
|
||||||
|
operator?: string;
|
||||||
|
operatorCategory?: { id: string; name: string; classes?: string[] };
|
||||||
|
service?: string;
|
||||||
|
vision: { class: string; confidence: number; categoryId?: string | null };
|
||||||
|
downgraded?: boolean;
|
||||||
|
image: { width: number; height: number; plateBlurred: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||||
|
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
function str(v: unknown, max = 200): string | null {
|
||||||
|
return typeof v === "string" && v.length > 0 && v.length <= max ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the meta part; returns a message on the first problem. */
|
||||||
|
function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } | { ok: false; why: string } {
|
||||||
|
if (!m || typeof m !== "object") return { ok: false, why: "meta must be an object" };
|
||||||
|
const x = m as Record<string, unknown>;
|
||||||
|
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
|
||||||
|
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
|
||||||
|
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
|
||||||
|
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
|
||||||
|
const kind = x.kind === undefined ? "wash" : x.kind;
|
||||||
|
if (kind !== "wash" && kind !== "entry") return { ok: false, why: "bad kind" };
|
||||||
|
const v = x.vision as Record<string, unknown> | undefined;
|
||||||
|
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
|
||||||
|
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
|
||||||
|
if (kind === "wash") {
|
||||||
|
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
|
||||||
|
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
|
||||||
|
const oc = x.operatorCategory as Record<string, unknown> | undefined;
|
||||||
|
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
|
||||||
|
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
|
||||||
|
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
|
||||||
|
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
|
||||||
|
}
|
||||||
|
const im = x.image as Record<string, unknown> | undefined;
|
||||||
|
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
|
||||||
|
return { ok: true, meta: x as unknown as IngestMeta };
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const ba = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectorApp extends FastifyInstance {
|
||||||
|
collectorDb: CollectorDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: string } = {}): Promise<CollectorApp> {
|
||||||
|
await mkdir(path.join(cfg.dataDir, "crops"), { recursive: true });
|
||||||
|
const db = new CollectorDb(opts.dbFile ?? path.join(cfg.dataDir, "collector.sqlite"));
|
||||||
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, bodyLimit: 64 * 1024 }) as unknown as CollectorApp;
|
||||||
|
app.collectorDb = db;
|
||||||
|
await app.register(multipart, { limits: { fileSize: MAX_IMAGE_BYTES, files: 1, fields: 4, parts: 6 } });
|
||||||
|
app.addHook("onClose", async () => db.close());
|
||||||
|
|
||||||
|
/** Which booth this bearer token belongs to, or null. Constant-time per candidate. */
|
||||||
|
function boothForToken(req: FastifyRequest): string | null {
|
||||||
|
const h = req.headers.authorization ?? "";
|
||||||
|
if (!h.startsWith("Bearer ")) return null;
|
||||||
|
const token = h.slice(7).trim();
|
||||||
|
let found: string | null = null;
|
||||||
|
for (const [booth, t] of cfg.boothTokens) if (safeEqual(token, t)) found = booth;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP Basic for the reviewer. */
|
||||||
|
async function requireReviewer(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
if (!cfg.reviewer) return reply.code(503).send({ error: "reviewer login not configured" });
|
||||||
|
const h = req.headers.authorization ?? "";
|
||||||
|
if (h.startsWith("Basic ")) {
|
||||||
|
const [user, ...rest] = Buffer.from(h.slice(6), "base64").toString("utf8").split(":");
|
||||||
|
const pass = rest.join(":");
|
||||||
|
if (user && safeEqual(user, cfg.reviewer.user) && safeEqual(pass, cfg.reviewer.pass)) return;
|
||||||
|
}
|
||||||
|
return reply.code(401).header("www-authenticate", 'Basic realm="wash review", charset="UTF-8"').send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/health", async () => {
|
||||||
|
const s = db.stats();
|
||||||
|
return { ok: true, booths: s.booths.length, pending: s.booths.reduce((n, b) => n + b.pending, 0) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Ingest (booths) -----------------------------------------------------------------
|
||||||
|
app.post("/ingest", async (req, reply) => {
|
||||||
|
const booth = boothForToken(req);
|
||||||
|
if (!booth) return reply.code(401).send({ error: "unauthorized" });
|
||||||
|
const claimed = req.headers["x-booth-id"];
|
||||||
|
if (typeof claimed === "string" && claimed !== booth) return reply.code(403).send({ error: "booth id does not match the token" });
|
||||||
|
if (!req.isMultipart()) return reply.code(415).send({ error: "multipart/form-data expected" });
|
||||||
|
|
||||||
|
let metaRaw: string | null = null;
|
||||||
|
let image: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
for await (const part of req.parts()) {
|
||||||
|
if (part.type === "file" && part.fieldname === "image") {
|
||||||
|
image = await part.toBuffer();
|
||||||
|
} else if (part.type === "field" && part.fieldname === "meta") {
|
||||||
|
metaRaw = String(part.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
return reply.code(code === "FST_REQ_FILE_TOO_LARGE" ? 413 : 400).send({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
if (!metaRaw) return reply.code(400).send({ error: "meta part missing" });
|
||||||
|
if (!image || image.length < 100) return reply.code(400).send({ error: "image part missing" });
|
||||||
|
if (!(image[0] === 0xff && image[1] === 0xd8 && image[2] === 0xff)) return reply.code(415).send({ error: "image must be a JPEG" });
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(metaRaw);
|
||||||
|
} catch {
|
||||||
|
return reply.code(400).send({ error: "meta is not JSON" });
|
||||||
|
}
|
||||||
|
const checked = checkMeta(parsed, booth);
|
||||||
|
if (!checked.ok) return reply.code(422).send({ error: checked.why });
|
||||||
|
const meta = checked.meta;
|
||||||
|
|
||||||
|
// Idempotent on the item id: a booth retrying after a lost 2xx must not duplicate.
|
||||||
|
if (db.get(meta.item)) return reply.code(200).send({ ok: true, duplicate: true });
|
||||||
|
|
||||||
|
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
|
||||||
|
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
|
||||||
|
await writeFile(path.join(cfg.dataDir, rel), image);
|
||||||
|
const kind = meta.kind ?? "wash";
|
||||||
|
db.insert({
|
||||||
|
id: meta.item,
|
||||||
|
booth,
|
||||||
|
kind,
|
||||||
|
orderRef: meta.order ?? "",
|
||||||
|
at: meta.at,
|
||||||
|
operatorRef: meta.operator ?? "",
|
||||||
|
operatorCategoryId: meta.operatorCategory?.id ?? "",
|
||||||
|
operatorCategoryName: meta.operatorCategory?.name ?? "",
|
||||||
|
operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
|
||||||
|
service: meta.service ?? "",
|
||||||
|
visionClass: meta.vision.class,
|
||||||
|
visionConfidence: meta.vision.confidence,
|
||||||
|
visionCategoryId: meta.vision.categoryId ?? null,
|
||||||
|
downgraded: meta.downgraded ? 1 : 0,
|
||||||
|
imageWidth: meta.image.width,
|
||||||
|
imageHeight: meta.image.height,
|
||||||
|
plateBlurred: meta.image.plateBlurred ? 1 : 0,
|
||||||
|
imagePath: rel,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
|
||||||
|
return reply.code(201).send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Review (the trusted person) -----------------------------------------------------
|
||||||
|
const page = reviewPage();
|
||||||
|
app.get("/", { preHandler: requireReviewer }, async (_req, reply) => reply.redirect("/review"));
|
||||||
|
app.get("/review", { preHandler: requireReviewer }, async (_req, reply) => reply.type("text/html; charset=utf-8").send(page));
|
||||||
|
|
||||||
|
app.get<{ Querystring: { status?: string; limit?: string; booth?: string } }>(
|
||||||
|
"/api/items",
|
||||||
|
{ preHandler: requireReviewer },
|
||||||
|
async (req) => {
|
||||||
|
const status = req.query.status === "reviewed" ? "reviewed" : "pending";
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 25, 1), 200);
|
||||||
|
return { items: db.list(status, limit, req.query.booth || undefined).map(publicItem) };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>("/api/items/:id/image", { preHandler: requireReviewer }, async (req, reply) => {
|
||||||
|
const row = db.get(req.params.id);
|
||||||
|
if (!row) return reply.code(404).send({ error: "not found" });
|
||||||
|
return reply.type("image/jpeg").header("cache-control", "private, max-age=3600").send(createReadStream(path.join(cfg.dataDir, row.imagePath)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { label?: unknown } }>("/api/items/:id/review", { preHandler: requireReviewer }, async (req, reply) => {
|
||||||
|
const label = req.body?.label;
|
||||||
|
if (label !== "unusable" && !isVehicleClass(label)) return reply.code(400).send({ error: "label must be a vehicle class or 'unusable'" });
|
||||||
|
if (!db.get(req.params.id)) return reply.code(404).send({ error: "not found" });
|
||||||
|
const row = db.review(req.params.id, label as ReviewVerdict, cfg.reviewer!.user);
|
||||||
|
return publicItem(row!);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/stats", { preHandler: requireReviewer }, async () => db.stats());
|
||||||
|
|
||||||
|
// --- Export (the training set) --------------------------------------------------------
|
||||||
|
app.get("/export/labels.csv", { preHandler: requireReviewer }, async (_req, reply) => {
|
||||||
|
const rows = db.labelled();
|
||||||
|
// Quote every cell; a cell starting like a spreadsheet formula (=, +, -, @, tab, CR)
|
||||||
|
// gets a leading apostrophe — the category/service names are booth-supplied text and
|
||||||
|
// the reviewer will open this in a spreadsheet (CSV formula injection).
|
||||||
|
const q = (s: string | number | null) => {
|
||||||
|
let v = String(s ?? "");
|
||||||
|
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
|
||||||
|
return `"${v.replace(/"/g, '""')}"`;
|
||||||
|
};
|
||||||
|
const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
|
||||||
|
const lines = rows.map((r) =>
|
||||||
|
[r.id, r.booth, r.kind, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
|
||||||
|
);
|
||||||
|
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row as the review screen sees it (no server paths). */
|
||||||
|
function publicItem(r: ItemRow): Omit<ItemRow, "imagePath"> {
|
||||||
|
const { imagePath: _p, ...rest } = r;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface CollectorConfig {
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly dataDir: string;
|
||||||
|
/** boothId → bearer token. */
|
||||||
|
readonly boothTokens: ReadonlyMap<string, string>;
|
||||||
|
/** The single reviewer login; null = review screen and export refuse (503). */
|
||||||
|
readonly reviewer: { readonly user: string; readonly pass: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
|
||||||
|
export function parseBoothTokens(raw: string): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const pair of raw.split(/[,\s]+/)) {
|
||||||
|
if (!pair) continue;
|
||||||
|
const i = pair.indexOf(":");
|
||||||
|
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
|
||||||
|
const booth = pair.slice(0, i).trim();
|
||||||
|
const token = pair.slice(i + 1).trim();
|
||||||
|
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
|
||||||
|
out.set(booth, token);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
|
||||||
|
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
|
||||||
|
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
|
||||||
|
return {
|
||||||
|
host: env.COLLECTOR_HOST ?? "0.0.0.0",
|
||||||
|
port: Number(env.COLLECTOR_PORT ?? 8090),
|
||||||
|
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
|
||||||
|
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
|
||||||
|
reviewer: user && pass.length >= 8 ? { user, pass } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import type { VehicleClass } from "@parking/shared";
|
||||||
|
|
||||||
|
// One table. Each row is one booth decision: what the camera saw, what the operator
|
||||||
|
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
|
||||||
|
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
|
||||||
|
// reads it straight off the volume.
|
||||||
|
|
||||||
|
export interface ItemRow {
|
||||||
|
id: string;
|
||||||
|
booth: string;
|
||||||
|
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
|
||||||
|
* training material: crop + the camera's class, operator fields empty). */
|
||||||
|
kind: "wash" | "entry";
|
||||||
|
orderRef: string;
|
||||||
|
at: string;
|
||||||
|
operatorRef: string;
|
||||||
|
operatorCategoryId: string;
|
||||||
|
operatorCategoryName: string;
|
||||||
|
/** The vision classes the operator's category covers at that site (its mapping) — what
|
||||||
|
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
|
||||||
|
operatorClasses: string;
|
||||||
|
service: string;
|
||||||
|
visionClass: string;
|
||||||
|
visionConfidence: number;
|
||||||
|
visionCategoryId: string | null;
|
||||||
|
downgraded: number;
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
plateBlurred: number;
|
||||||
|
imagePath: string;
|
||||||
|
receivedAt: string;
|
||||||
|
reviewLabel: string | null; // a VehicleClass, or "unusable"
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewVerdict = VehicleClass | "unusable";
|
||||||
|
|
||||||
|
export class CollectorDb {
|
||||||
|
readonly #db: Database.Database;
|
||||||
|
|
||||||
|
constructor(file: string) {
|
||||||
|
this.#db = new Database(file);
|
||||||
|
this.#db.pragma("journal_mode = WAL");
|
||||||
|
this.#db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
booth TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'wash',
|
||||||
|
order_ref TEXT NOT NULL,
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
operator_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_category_id TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_category_name TEXT NOT NULL DEFAULT '',
|
||||||
|
operator_classes TEXT NOT NULL DEFAULT '[]',
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
vision_class TEXT NOT NULL,
|
||||||
|
vision_confidence REAL NOT NULL,
|
||||||
|
vision_category_id TEXT,
|
||||||
|
downgraded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
image_width INTEGER NOT NULL,
|
||||||
|
image_height INTEGER NOT NULL,
|
||||||
|
plate_blurred INTEGER NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
review_label TEXT,
|
||||||
|
reviewed_at TEXT,
|
||||||
|
reviewer TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.#db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static #map(r: Record<string, unknown>): ItemRow {
|
||||||
|
return {
|
||||||
|
id: r.id as string,
|
||||||
|
booth: r.booth as string,
|
||||||
|
kind: r.kind === "entry" ? "entry" : "wash",
|
||||||
|
orderRef: r.order_ref as string,
|
||||||
|
at: r.at as string,
|
||||||
|
operatorRef: r.operator_ref as string,
|
||||||
|
operatorCategoryId: r.operator_category_id as string,
|
||||||
|
operatorCategoryName: r.operator_category_name as string,
|
||||||
|
operatorClasses: r.operator_classes as string,
|
||||||
|
service: r.service as string,
|
||||||
|
visionClass: r.vision_class as string,
|
||||||
|
visionConfidence: r.vision_confidence as number,
|
||||||
|
visionCategoryId: (r.vision_category_id as string | null) ?? null,
|
||||||
|
downgraded: r.downgraded as number,
|
||||||
|
imageWidth: r.image_width as number,
|
||||||
|
imageHeight: r.image_height as number,
|
||||||
|
plateBlurred: r.plate_blurred as number,
|
||||||
|
imagePath: r.image_path as string,
|
||||||
|
receivedAt: r.received_at as string,
|
||||||
|
reviewLabel: (r.review_label as string | null) ?? null,
|
||||||
|
reviewedAt: (r.reviewed_at as string | null) ?? null,
|
||||||
|
reviewer: (r.reviewer as string | null) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): ItemRow | null {
|
||||||
|
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||||
|
return r ? CollectorDb.#map(r) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
|
||||||
|
this.#db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
|
||||||
|
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
|
||||||
|
image_width, image_height, plate_blurred, image_path, received_at)
|
||||||
|
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
|
||||||
|
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
|
||||||
|
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
|
||||||
|
)
|
||||||
|
.run(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
|
||||||
|
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (booth) {
|
||||||
|
where.push("booth = ?");
|
||||||
|
params.push(booth);
|
||||||
|
}
|
||||||
|
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
|
||||||
|
const rows = this.#db
|
||||||
|
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
|
||||||
|
.all(...params, limit) as Record<string, unknown>[];
|
||||||
|
return rows.map((r) => CollectorDb.#map(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
|
||||||
|
this.#db
|
||||||
|
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
|
||||||
|
.run(label, new Date().toISOString(), reviewer, id);
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
|
||||||
|
* reviewer's class fell inside the operator's chosen category (agree) or outside
|
||||||
|
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
|
||||||
|
stats(): {
|
||||||
|
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
||||||
|
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
|
||||||
|
} {
|
||||||
|
const booths = this.#db
|
||||||
|
.prepare(
|
||||||
|
`SELECT booth, COUNT(*) AS received,
|
||||||
|
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
|
||||||
|
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
|
||||||
|
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
|
||||||
|
FROM items GROUP BY booth ORDER BY booth`,
|
||||||
|
)
|
||||||
|
.all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
|
||||||
|
// Operator agreement is a WASH thing — an entry sample has no operator decision.
|
||||||
|
const reviewed = this.#db
|
||||||
|
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
|
||||||
|
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
|
||||||
|
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
|
||||||
|
for (const r of reviewed) {
|
||||||
|
const key = `${r.booth} ${r.operator_ref}`;
|
||||||
|
let o = ops.get(key);
|
||||||
|
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
|
||||||
|
o.reviewed += 1;
|
||||||
|
if (r.review_label === "unusable") o.unusable += 1;
|
||||||
|
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
|
||||||
|
else o.disagree += 1;
|
||||||
|
}
|
||||||
|
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reviewed, usable rows — the training set. */
|
||||||
|
labelled(): ItemRow[] {
|
||||||
|
const rows = this.#db
|
||||||
|
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
|
||||||
|
.all() as Record<string, unknown>[];
|
||||||
|
return rows.map((r) => CollectorDb.#map(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { buildCollector } from "./app.js";
|
||||||
|
import { configFromEnv } from "./config.js";
|
||||||
|
|
||||||
|
const cfg = configFromEnv();
|
||||||
|
const app = await buildCollector(cfg);
|
||||||
|
if (cfg.boothTokens.size === 0) app.log.warn("COLLECTOR_BOOTH_TOKENS is empty — no booth can ingest");
|
||||||
|
if (!cfg.reviewer) app.log.warn("COLLECTOR_REVIEWER_USER/PASS not set — the review screen and export refuse");
|
||||||
|
app.log.info(`collector: ${cfg.boothTokens.size} booth token(s), data in ${cfg.dataDir}`);
|
||||||
|
await app.listen({ host: cfg.host, port: cfg.port });
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", () => void stop());
|
||||||
|
process.on("SIGINT", () => void stop());
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { VEHICLE_CLASSES } from "@parking/shared";
|
||||||
|
|
||||||
|
// The reviewer's screen: one pending crop at a time, the operator's pick and the camera's
|
||||||
|
// pick beside it, one button per vocabulary class + "unusable". Served by the collector
|
||||||
|
// itself (no build step, no framework) — this is deliberately the whole UI.
|
||||||
|
|
||||||
|
export function reviewPage(): string {
|
||||||
|
const classes = JSON.stringify(VEHICLE_CLASSES);
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Wash review</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#111; --panel:#1b1b1b; --text:#e8e8e8; --muted:#9a9a9a; --amber:#e0a030; --green:#4caf50; --red:#e05050; }
|
||||||
|
body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, sans-serif; }
|
||||||
|
header { display:flex; justify-content:space-between; align-items:center; padding:.6rem 1rem; border-bottom:1px solid #333; }
|
||||||
|
header b { letter-spacing:.08em; text-transform:uppercase; color:var(--amber); font-size:.75rem; }
|
||||||
|
main { max-width:960px; margin:0 auto; padding:1rem; display:grid; gap:1rem; }
|
||||||
|
.card { background:var(--panel); border:1px solid #333; border-radius:6px; padding:1rem; }
|
||||||
|
img { max-width:100%; max-height:60vh; display:block; margin:0 auto; background:#000; border-radius:4px; }
|
||||||
|
dl { display:grid; grid-template-columns:max-content 1fr; gap:.2rem .8rem; margin:0; font-variant-numeric:tabular-nums; }
|
||||||
|
dt { color:var(--muted); }
|
||||||
|
.buttons { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||||
|
button { background:#2a2a2a; color:var(--text); border:1px solid #444; border-radius:4px; padding:.5rem .8rem; font:inherit; cursor:pointer; }
|
||||||
|
button:hover { border-color:var(--amber); }
|
||||||
|
button.mono { font-family:ui-monospace, monospace; }
|
||||||
|
button.hint { border-color:var(--amber); }
|
||||||
|
button.unusable { color:var(--red); }
|
||||||
|
button.skip { color:var(--muted); }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.warn { color:var(--amber); }
|
||||||
|
table { border-collapse:collapse; width:100%; font-variant-numeric:tabular-nums; }
|
||||||
|
td, th { text-align:left; padding:.2rem .5rem; border-bottom:1px solid #2a2a2a; }
|
||||||
|
th { color:var(--muted); font-weight:normal; font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; }
|
||||||
|
kbd { background:#2a2a2a; border:1px solid #444; border-radius:3px; padding:0 .3rem; font-size:.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header><b>Wash review</b><span id="counts" class="muted"></span></header>
|
||||||
|
<main>
|
||||||
|
<section class="card" id="item">
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<table id="stats"><thead><tr><th>booth</th><th>operator</th><th>reviewed</th><th>agree</th><th>disagree</th><th>unusable</th></tr></thead><tbody></tbody></table>
|
||||||
|
</section>
|
||||||
|
<p class="muted">Keys: <kbd>1</kbd>–<kbd>9</kbd>, <kbd>0</kbd> pick a class in order · <kbd>u</kbd> unusable · <kbd>s</kbd> skip. Skipped items come back after a reload. Your verdict is the training label; the operator's pick is only compared against it.</p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const CLASSES = ${classes};
|
||||||
|
const skipped = new Set();
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
async function api(path, init) {
|
||||||
|
const r = await fetch(path, init);
|
||||||
|
if (!r.ok) throw new Error(path + ' → HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const s = await api('/api/stats');
|
||||||
|
const pending = s.booths.reduce((n, b) => n + b.pending, 0);
|
||||||
|
const reviewed = s.booths.reduce((n, b) => n + b.reviewed, 0);
|
||||||
|
document.getElementById('counts').textContent = pending + ' waiting · ' + reviewed + ' reviewed';
|
||||||
|
const tb = document.querySelector('#stats tbody');
|
||||||
|
tb.innerHTML = s.operators.map(o => '<tr><td>' + esc(o.booth) + '</td><td class="mono">' + esc(o.operatorRef) + '</td><td>' + o.reviewed + '</td><td>' + o.agree + '</td><td' + (o.disagree ? ' class="warn"' : '') + '>' + o.disagree + '</td><td>' + o.unusable + '</td></tr>').join('') || '<tr><td colspan="6" class="muted">nothing reviewed yet</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
const { items } = await api('/api/items?status=pending&limit=25');
|
||||||
|
current = items.find(i => !skipped.has(i.id)) || null;
|
||||||
|
const el = document.getElementById('item');
|
||||||
|
if (!current) { el.innerHTML = '<p class="muted">Nothing waiting for review.</p>'; return; }
|
||||||
|
const it = current;
|
||||||
|
const opClasses = JSON.parse(it.operatorClasses || '[]');
|
||||||
|
el.innerHTML =
|
||||||
|
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
|
||||||
|
'<dl style="margin-top:.8rem">' +
|
||||||
|
(it.kind === 'entry'
|
||||||
|
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
|
||||||
|
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
|
||||||
|
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
|
||||||
|
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
|
||||||
|
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
|
||||||
|
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
|
||||||
|
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
|
||||||
|
'</dl>' +
|
||||||
|
'<div class="buttons" style="margin-top:.8rem">' +
|
||||||
|
CLASSES.map((c, i) => '<button class="mono' + (c === it.visionClass ? ' hint' : '') + '" data-label="' + c + '" title="key ' + ((i + 1) % 10) + '">' + c + '</button>').join('') +
|
||||||
|
'<button class="unusable" data-label="unusable">unusable</button>' +
|
||||||
|
'<button class="skip" data-skip="1">skip</button>' +
|
||||||
|
'</div>';
|
||||||
|
el.querySelectorAll('button[data-label]').forEach(b => b.addEventListener('click', () => verdict(b.dataset.label)));
|
||||||
|
el.querySelector('button[data-skip]').addEventListener('click', skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verdict(label) {
|
||||||
|
if (!current) return;
|
||||||
|
await api('/api/items/' + encodeURIComponent(current.id) + '/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label }) });
|
||||||
|
await Promise.all([next(), loadStats()]);
|
||||||
|
}
|
||||||
|
function skip() { if (current) { skipped.add(current.id); next(); } }
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
if (e.key === 'u') verdict('unusable');
|
||||||
|
else if (e.key === 's') skip();
|
||||||
|
else if (/^[0-9]$/.test(e.key)) { const i = e.key === '0' ? 9 : Number(e.key) - 1; if (CLASSES[i]) verdict(CLASSES[i]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
next().catch(e => { document.getElementById('item').innerHTML = '<p class="warn">' + esc(e.message) + '</p>'; });
|
||||||
|
loadStats().catch(() => {});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"references": [{ "path": "../../packages/shared" }],
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: { include: ["src/**/*.test.ts"], env: { LOG_LEVEL: "silent" } },
|
||||||
|
});
|
||||||
@@ -44,6 +44,14 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd
|
|||||||
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
|
||||||
committed.
|
committed.
|
||||||
|
|
||||||
|
**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`,
|
||||||
|
and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app
|
||||||
|
was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`,
|
||||||
|
so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected,
|
||||||
|
and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not
|
||||||
|
supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old
|
||||||
|
version; the failure is logged to the server's Logs viewer.
|
||||||
|
|
||||||
## Release gate — run the REAL bundle locally before tagging
|
## Release gate — run the REAL bundle locally before tagging
|
||||||
|
|
||||||
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
|
`tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle
|
||||||
|
|||||||
@@ -77,3 +77,30 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
|||||||
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
# camera (the camera's config.anpr checkbox in Setup); the camera must be BOUND to a relay.
|
||||||
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
# VISION_ENTRY_MIN_CONFIDENCE=0.85 # stricter floor for a BARRIER-driving read (near-miss → falls back to card/QR)
|
||||||
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
# ANPR_DEBOUNCE_MS=12000 # same plate/camera within this window = ONE presentation (camera re-fires ~1Hz)
|
||||||
|
|
||||||
|
# Venue modules --------------------------------------------------------------
|
||||||
|
# Comma-separated ids of the modules this site is ENTITLED to (a vendor/deployment
|
||||||
|
# decision — set in the Komodo stack env, never by a site role). The site admin then
|
||||||
|
# ACTIVATES within this set in Setup → Site; effective = entitled ∩ activated. Unset or
|
||||||
|
# blank = every registered module (parking,validation,carwash) — a DEV convenience. In
|
||||||
|
# Docker, docker-compose.yml forwards it with a default of parking,validation, so a booth
|
||||||
|
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||||
|
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||||
|
#MODULES_ENTITLED=parking,validation
|
||||||
|
|
||||||
|
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
|
||||||
|
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
|
||||||
|
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
|
||||||
|
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
|
||||||
|
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
|
||||||
|
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
|
||||||
|
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
|
||||||
|
# CARWASH_REVIEW_URL=
|
||||||
|
# CARWASH_REVIEW_TOKEN=
|
||||||
|
# CARWASH_REVIEW_BOOTH_ID=
|
||||||
|
# CARWASH_REVIEW_INTERVAL_SEC=60
|
||||||
|
# Entry-stream sampling: also queue one in N ENTRY vehicle reads (no wash, no operator) as
|
||||||
|
# pure training material in the gate view — many times the wash stream, zero domain shift.
|
||||||
|
# 1 = every entry (the reviewer labels what they have time for; the rest waits and stays
|
||||||
|
# useful), N = one in N, 0/unset = off. Needs the three settings above.
|
||||||
|
# CARWASH_REVIEW_ENTRY_SAMPLE=1
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
|||||||
COPY apps/server/package.json apps/server/
|
COPY apps/server/package.json apps/server/
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
COPY apps/vision/package.json apps/vision/
|
COPY apps/vision/package.json apps/vision/
|
||||||
|
COPY apps/collector/package.json apps/collector/
|
||||||
COPY packages/db/package.json packages/db/
|
COPY packages/db/package.json packages/db/
|
||||||
COPY packages/devices/package.json packages/devices/
|
COPY packages/devices/package.json packages/devices/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
|
|||||||
+52
-3
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { eq, rolePermissions, type Db } from "@parking/db";
|
import { eq, rolePermissions, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
||||||
|
|
||||||
// Local JWT auth helpers — fully local, no external identity provider
|
// Local JWT auth helpers — fully local, no external identity provider
|
||||||
@@ -143,10 +143,41 @@ export function initAuth(db: Db): void {
|
|||||||
permsCache.clear();
|
permsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the permission cache. Call after ANY write to roles / role_permissions
|
/** Clear the permission + role caches. Call after ANY write to roles / role_permissions
|
||||||
* (or a user's roleId) so the change takes effect on the next request. */
|
* or to a user's roleId / deletion, so the change takes effect on the next request. */
|
||||||
export function bumpPermsCache(): void {
|
export function bumpPermsCache(): void {
|
||||||
permsCache.clear();
|
permsCache.clear();
|
||||||
|
roleCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** userId → CURRENT roleId, cached until bumpPermsCache(). */
|
||||||
|
const roleCache = new Map<string, string | null>();
|
||||||
|
|
||||||
|
/** The user's CURRENT role. The token pins the roleId that was current at LOGIN; an
|
||||||
|
* admin reassigning a user's role (or deleting the user) must take effect on the next
|
||||||
|
* request exactly like editing a role does — otherwise the reassigned user keeps the
|
||||||
|
* old role's rights until they log out (found 2026-09-05: a user moved to a new
|
||||||
|
* wash role kept 403ing on the new role's permissions). null = the user is gone. */
|
||||||
|
export function currentRoleId(sub: string): string | null {
|
||||||
|
if (!authDb) throw new Error("auth not initialised (call initAuth)");
|
||||||
|
const hit = roleCache.get(sub);
|
||||||
|
if (hit !== undefined) return hit;
|
||||||
|
const row = authDb
|
||||||
|
.select({ roleId: users.roleId, deletedAt: users.deletedAt })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, sub))
|
||||||
|
.get();
|
||||||
|
const roleId = row && row.deletedAt == null ? row.roleId : null;
|
||||||
|
roleCache.set(sub, roleId);
|
||||||
|
return roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After jwtVerify: replace the token's pinned roleId with the user's current one, or
|
||||||
|
* end the session if the user no longer exists. */
|
||||||
|
function refreshRole(req: FastifyRequest): void {
|
||||||
|
const roleId = currentRoleId(req.user.sub);
|
||||||
|
if (roleId === null) throw Object.assign(new Error("session no longer valid"), { statusCode: 401 });
|
||||||
|
if (roleId !== req.user.roleId) req.user.roleId = roleId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The permission set for a role id, cached. `admin` is always the full set. */
|
/** The permission set for a role id, cached. `admin` is always the full set. */
|
||||||
@@ -184,12 +215,29 @@ export function requirePermission(...required: Permission[]) {
|
|||||||
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
await req.jwtVerify(); // reads the token cookie (configured in server.ts)
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
if (!req.user || !roleHasPermissions(req.user.roleId, required)) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* preHandler guard satisfied by ANY ONE of the listed permissions — for a read that
|
||||||
|
* two jobs legitimately share (a module's master data: the desk that works with it
|
||||||
|
* reads it under the module's own permission, Setup reads it under site:read).
|
||||||
|
*/
|
||||||
|
export function requireAnyPermission(...anyOf: Permission[]) {
|
||||||
|
return async (req: FastifyRequest, _reply: FastifyReply) => {
|
||||||
|
await req.jwtVerify();
|
||||||
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
|
if (!req.user || !anyOf.some((p) => roleHasPermissions(req.user!.roleId, [p]))) {
|
||||||
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* preHandler that requires a valid signed-in session but NO specific permission —
|
* preHandler that requires a valid signed-in session but NO specific permission —
|
||||||
* for "about me" routes (/me, change own language) every authenticated user may
|
* for "about me" routes (/me, change own language) every authenticated user may
|
||||||
@@ -201,4 +249,5 @@ export async function requireAuth(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await req.jwtVerify();
|
await req.jwtVerify();
|
||||||
assertCsrf(req);
|
assertCsrf(req);
|
||||||
|
refreshRole(req);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type PrinterInstance,
|
type PrinterInstance,
|
||||||
type ReceiptData,
|
type ReceiptData,
|
||||||
type TicketHeader,
|
type TicketHeader,
|
||||||
|
printerRoleOf,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { devicesByDirection } from "./device-resolve.js";
|
import { devicesByDirection } from "./device-resolve.js";
|
||||||
@@ -41,7 +42,7 @@ function loadPrinters(db: Db): PrinterInstance[] {
|
|||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) continue;
|
if (!driver) continue;
|
||||||
const cfg = row.config as Record<string, unknown>;
|
const cfg = row.config as Record<string, unknown>;
|
||||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
const role = printerRoleOf(cfg);
|
||||||
try {
|
try {
|
||||||
out.push({
|
out.push({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { PrinterStatus } from "@parking/devices";
|
import type { PrinterStatus } from "@parking/devices";
|
||||||
import type { LedgerEventRow } from "@parking/db";
|
import type { LedgerEventRow } from "@parking/db";
|
||||||
|
import type { VehicleRead } from "@parking/shared";
|
||||||
|
|
||||||
// Internal event bus for device-originated events (button presses, etc.).
|
// Internal event bus for device-originated events (button presses, etc.).
|
||||||
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
// Hardware drivers / inbound device pushes emit here; business logic (entry
|
||||||
@@ -52,7 +53,7 @@ export interface ReadOutcome {
|
|||||||
export interface PrinterStatusEvent {
|
export interface PrinterStatusEvent {
|
||||||
readonly deviceId: string; // devices id
|
readonly deviceId: string; // devices id
|
||||||
readonly driverId: string;
|
readonly driverId: string;
|
||||||
readonly role?: string; // entry-dispenser | booth-receipt
|
readonly role?: string; // entry-dispenser | booth-receipt | wash-desk
|
||||||
readonly status: PrinterStatus;
|
readonly status: PrinterStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +75,10 @@ export interface DeviceStatusEvent {
|
|||||||
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
* chip reads e.g. "Lexuesi hyrje" / "Kamera dalje" / "Printer kabina":
|
||||||
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
* - reader/camera: "entry" | "exit" | "both" (inherited from its bound relay)
|
||||||
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
* - access: "entry" | "exit" | "both" | "mixed" (from its relays[])
|
||||||
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt)
|
* - printer: "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||||
* - undetermined: null (chip shows the category alone)
|
* - undetermined: null (chip shows the category alone)
|
||||||
*/
|
*/
|
||||||
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
readonly roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
|
||||||
readonly state: "ready" | "degraded" | "offline";
|
readonly state: "ready" | "degraded" | "offline";
|
||||||
readonly detail?: string;
|
readonly detail?: string;
|
||||||
readonly checkedAt: string; // ISO-8601
|
readonly checkedAt: string; // ISO-8601
|
||||||
@@ -105,6 +106,17 @@ export interface PlateRecognizedEvent {
|
|||||||
readonly direction: "entry" | "exit";
|
readonly direction: "entry" | "exit";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emitted when vision classified the vehicle in an entry/exit frame (advisory; stored on
|
||||||
|
* the read row like the plate). A module may sample these — the Car Wash review outbox
|
||||||
|
* queues one in N ENTRY reads for the remote reviewer, in the gate view the classifier
|
||||||
|
* will be trained on (wiki/concepts/vision-review-outbox.md). The core emits; it never
|
||||||
|
* knows who listens. */
|
||||||
|
export interface VehicleReadEvent {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly direction: "entry" | "exit";
|
||||||
|
readonly read: VehicleRead;
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
/** Per-lane RADAR presence — a vehicle-presence INPUT (loop/radar) is shorted at the
|
||||||
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
* entry/exit barrier, i.e. "something is in the lane vicinity" BEFORE the camera has
|
||||||
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
* confirmed a vehicle. Same signal that makes the physical button lamp (relay 3) blink:
|
||||||
@@ -197,6 +209,13 @@ class DeviceEventBus extends EventEmitter {
|
|||||||
this.on("plate-recognized", cb);
|
this.on("plate-recognized", cb);
|
||||||
return () => this.off("plate-recognized", cb);
|
return () => this.off("plate-recognized", cb);
|
||||||
}
|
}
|
||||||
|
emitVehicleRead(event: VehicleReadEvent): void {
|
||||||
|
this.emit("vehicle-read", event);
|
||||||
|
}
|
||||||
|
onVehicleRead(cb: (event: VehicleReadEvent) => void): () => void {
|
||||||
|
this.on("vehicle-read", cb);
|
||||||
|
return () => this.off("vehicle-read", cb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Process-wide device event bus. */
|
/** Process-wide device event bus. */
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function localIsoWithOffset(tz: string, at = new Date()): string {
|
|||||||
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
* - reader/camera → the direction inherited from its bound relay (entry/exit/both)
|
||||||
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
* - access → entry/exit/both from its relays[]; "mixed" if it spans more
|
||||||
* than one direction; null if it declares none yet
|
* than one direction; null if it declares none yet
|
||||||
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt)
|
* - printer → "lane" (entry-dispenser) | "booth" (booth-receipt) | "wash" (wash-desk)
|
||||||
*/
|
*/
|
||||||
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
||||||
switch (row.category) {
|
switch (row.category) {
|
||||||
@@ -89,6 +89,7 @@ function roleKindOf(db: Db, row: DeviceRow): DeviceStatusEvent["roleKind"] {
|
|||||||
const role = (row.config as { role?: string }).role;
|
const role = (row.config as { role?: string }).role;
|
||||||
if (role === "booth-receipt") return "booth";
|
if (role === "booth-receipt") return "booth";
|
||||||
if (role === "entry-dispenser") return "lane";
|
if (role === "entry-dispenser") return "lane";
|
||||||
|
if (role === "wash-desk") return "wash";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type PrinterInstance,
|
type PrinterInstance,
|
||||||
type TicketData,
|
type TicketData,
|
||||||
type TicketHeader,
|
type TicketHeader,
|
||||||
|
printerRoleOf,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
@@ -523,7 +524,7 @@ export class EntryFlow {
|
|||||||
const driver = registry.get(row.driverId);
|
const driver = registry.get(row.driverId);
|
||||||
if (!driver) continue;
|
if (!driver) continue;
|
||||||
const cfg = row.config as Record<string, unknown>;
|
const cfg = row.config as Record<string, unknown>;
|
||||||
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
const role = printerRoleOf(cfg);
|
||||||
try {
|
try {
|
||||||
out.push({
|
out.push({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "./server.js";
|
||||||
|
import { seedUser, login } from "./test-helpers.js";
|
||||||
|
|
||||||
|
// Venue modules — entitled ∩ activated, enforced server-side (wiki/decisions/
|
||||||
|
// venue-modules.md). Boots the real app over an in-memory DB and drives it with
|
||||||
|
// app.inject, like routes.test.ts.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
const savedEnv = process.env.MODULES_ENTITLED;
|
||||||
|
|
||||||
|
async function boot(): Promise<void> {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
await boot();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
if (savedEnv === undefined) delete process.env.MODULES_ENTITLED;
|
||||||
|
else process.env.MODULES_ENTITLED = savedEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function admin() {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("defaults (no env, nothing activated)", () => {
|
||||||
|
it("every registered module is entitled, activated and effective; /me carries the set", async () => {
|
||||||
|
const { cookie } = await admin();
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.statusCode).toBe(200);
|
||||||
|
const body = cfg.json();
|
||||||
|
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
|
||||||
|
|
||||||
|
// A module route answers normally while the module is on.
|
||||||
|
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||||
|
expect(programs.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activation (site admin)", () => {
|
||||||
|
it("deactivating validation 403s its routes with module_disabled, signs a config_change, and is reversible", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking"]);
|
||||||
|
expect(put.json().modulesActivated).toEqual(["parking"]);
|
||||||
|
|
||||||
|
// The merchant scan routes are the module → 403; the PROGRAM routes are core (the
|
||||||
|
// discount engine serves Car Wash too) → still 200 with validation off.
|
||||||
|
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||||
|
expect(off.statusCode).toBe(403);
|
||||||
|
expect(off.json().code).toBe("module_disabled");
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
|
||||||
|
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
|
||||||
|
expect(me.json().modules).toEqual(["parking"]);
|
||||||
|
|
||||||
|
// The flip is on the signed ledger, attributed.
|
||||||
|
const events = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
expect(events.statusCode).toBe(200);
|
||||||
|
const list = (events.json().events ?? events.json()) as Array<{ type: string; payload: Record<string, unknown> }>;
|
||||||
|
const flip = list.find((e) => e.type === "config_change" && e.payload?.setting === "modules.validation");
|
||||||
|
expect(flip).toBeTruthy();
|
||||||
|
expect(flip!.payload).toMatchObject({ value: false, prev: true, operator: "boss" });
|
||||||
|
|
||||||
|
// Nothing was deleted: re-enable and the route is back.
|
||||||
|
const back = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation"] },
|
||||||
|
});
|
||||||
|
expect(back.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
const on = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
|
||||||
|
expect(on.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required modules cannot be deactivated (parking is always included)", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: [] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown ids with 400", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "bar"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carwash runs without the validation module (the discount engine is core)", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "carwash"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(200);
|
||||||
|
expect(put.json().modules).toEqual(["parking", "carwash"]);
|
||||||
|
// The wash's sponsorship program is still composable and readable.
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie } })).statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dependency rule: a module cannot be on while a module it depends on is off", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
// Every non-required module depends on parking, and parking is required — so the rule
|
||||||
|
// is exercised through the effective-set helper directly.
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
expect(shared.resolveModuleActivation(["parking", "validation", "carwash"], ["carwash"])).toMatchObject({ ok: true });
|
||||||
|
expect(shared.effectiveModules(["parking", "carwash"], ["parking", "carwash"])).toEqual(["parking", "carwash"]);
|
||||||
|
expect(cookie && csrf).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a no-op resave signs nothing", async () => {
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
const before = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
const countBefore = ((before.json().events ?? before.json()) as unknown[]).length;
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation", "carwash"] },
|
||||||
|
});
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
|
||||||
|
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("entitlement (vendor env)", () => {
|
||||||
|
it("MODULES_ENTITLED=parking: validation is neither offered nor activatable, and its routes 403", async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
process.env.MODULES_ENTITLED = "parking";
|
||||||
|
await boot();
|
||||||
|
const { cookie, csrf } = await admin();
|
||||||
|
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.json().modulesEntitled).toEqual(["parking"]);
|
||||||
|
expect(cfg.json().modules).toEqual(["parking"]);
|
||||||
|
|
||||||
|
const put = await app.inject({
|
||||||
|
method: "PUT", url: "/api/site-config",
|
||||||
|
headers: { cookie, "x-csrf-token": csrf },
|
||||||
|
payload: { modules: ["parking", "validation"] },
|
||||||
|
});
|
||||||
|
expect(put.statusCode).toBe(400);
|
||||||
|
expect(put.json().error).toMatch(/not entitled/);
|
||||||
|
|
||||||
|
const off = await app.inject({ method: "GET", url: "/api/validation/mine", headers: { cookie } });
|
||||||
|
expect(off.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required modules are entitled even when the env omits them; unknown ids are ignored", async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
process.env.MODULES_ENTITLED = "validation,bogus";
|
||||||
|
await boot();
|
||||||
|
const { cookie } = await admin();
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
|
||||||
|
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
|
||||||
|
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
|
||||||
|
const shared = await import("@parking/shared");
|
||||||
|
it("each till is guarded by its own module's permissions", () => {
|
||||||
|
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
|
||||||
|
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
|
||||||
|
const wash = new Set(["carwash:read", "carwash:cash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
|
||||||
|
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
|
||||||
|
// Module off → its till is not even addressable.
|
||||||
|
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
it("the live feed admits by watch permission and filters ledger events by their module", () => {
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
|
||||||
|
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
|
||||||
|
);
|
||||||
|
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
|
||||||
|
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
|
||||||
|
expect(shared.feedPermissionFor("payment")).toBe("event:read");
|
||||||
|
expect(shared.feedPermissionFor("validation")).toBe("event:read");
|
||||||
|
});
|
||||||
|
it("every job's permissions exist in the grid", () => {
|
||||||
|
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import {
|
||||||
|
effectiveModules,
|
||||||
|
isModuleId,
|
||||||
|
isTillId,
|
||||||
|
parseEntitledModules,
|
||||||
|
tillGuards,
|
||||||
|
tillsFor,
|
||||||
|
tillsOf,
|
||||||
|
type ModuleId,
|
||||||
|
type TillGuards,
|
||||||
|
type TillId,
|
||||||
|
} from "@parking/shared";
|
||||||
|
import { requireAuth, roleHasPermissions } from "./auth.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** Set by requireTill(): the till this request addresses (already authorized). */
|
||||||
|
till?: TillId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
|
||||||
|
// @parking/shared; design in wiki/decisions/venue-modules.md).
|
||||||
|
//
|
||||||
|
// entitled MODULES_ENTITLED env (vendor, Komodo stack) — unset = everything.
|
||||||
|
// activated site_config.modules_json (site admin, Setup → Site) — null = everything
|
||||||
|
// entitled.
|
||||||
|
// effective what requireModule() enforces and what /api/auth/me + /api/site-config
|
||||||
|
// hand the SPA so it can hide nav. The web only HIDES; this file ENFORCES.
|
||||||
|
//
|
||||||
|
// Both inputs are re-read per request: one env read and one single-row SELECT on the
|
||||||
|
// site_config singleton — cheap, and it means a change takes effect on the next request
|
||||||
|
// with no cache to invalidate (the same reason the presence-bypass flags aren't cached).
|
||||||
|
|
||||||
|
/** The modules this deployment is entitled to. Unknown ids in the env are ignored
|
||||||
|
* (logged once at boot by registerModules). */
|
||||||
|
export function entitledModules(): ModuleId[] {
|
||||||
|
return parseEntitledModules(process.env.MODULES_ENTITLED).entitled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the persisted activation list off a site_config row. null = never set. A
|
||||||
|
* corrupt/unknown value is treated as "never set" rather than locking modules off. */
|
||||||
|
export function activatedModulesOf(row: { modulesJson?: string | null } | undefined): ModuleId[] | null {
|
||||||
|
const raw = row?.modulesJson;
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
return parsed.filter(isModuleId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective set for this site right now. */
|
||||||
|
export function effectiveModulesFor(db: Db): ModuleId[] {
|
||||||
|
const row = db.select({ modulesJson: siteConfig.modulesJson }).from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
return effectiveModules(entitledModules(), activatedModulesOf(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills available at this site right now: the booth, plus each effective
|
||||||
|
* money-taking module's own till (registry order). */
|
||||||
|
export function effectiveTillsFor(db: Db): TillId[] {
|
||||||
|
return tillsOf(effectiveModulesFor(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tills a role may SEE (default) or WORK (`shift` / `cash`) here: the effective
|
||||||
|
* tills whose module guard the role holds (each desk's money is guarded by that desk's
|
||||||
|
* own permissions — venue-modules.md §"Permissions matrix"). */
|
||||||
|
export function tillsReadableBy(db: Db, roleId: string, kind: keyof TillGuards = "read"): TillId[] {
|
||||||
|
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]), kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler factory for the shift/drawer routes: authenticate, parse the `till`
|
||||||
|
* (query on GET, body on POST; absent = booth; 400 `bad_till` when unknown or its
|
||||||
|
* module is off), then require the role to hold THAT TILL's guard for `kind` (403
|
||||||
|
* `till_forbidden`). The authorized till lands on `req.till`. The permission is thus
|
||||||
|
* resolved from the till, never fixed: the booth checks `shift:read`/`shift:create`/
|
||||||
|
* `drawer:create`, the wash `carwash:read`/`carwash:cash`. */
|
||||||
|
export function requireTill(db: Db, kind: keyof TillGuards, from: "query" | "body") {
|
||||||
|
return async (req: FastifyRequest, reply: FastifyReply): Promise<void | FastifyReply> => {
|
||||||
|
await requireAuth(req, reply);
|
||||||
|
const raw = from === "query" ? (req.query as { till?: unknown } | undefined)?.till : (req.body as { till?: unknown } | undefined)?.till;
|
||||||
|
const till = parseTill(db, raw);
|
||||||
|
if (!till) {
|
||||||
|
await reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
if (!roleHasPermissions(req.user.roleId, [tillGuards(till)[kind]])) {
|
||||||
|
await reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: `your role cannot ${kind === "read" ? "see" : "work"} the ${till} till`, code: "till_forbidden", till });
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
req.till = till;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
|
||||||
|
* whose module is not effective here, → null (the caller answers 400). */
|
||||||
|
export function parseTill(db: Db, raw: unknown): TillId | null {
|
||||||
|
if (raw == null || raw === "") return "booth";
|
||||||
|
if (!isTillId(raw)) return null;
|
||||||
|
return effectiveTillsFor(db).includes(raw) ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** preHandler: reject the call when `id` is not effective at this site. Compose it
|
||||||
|
* BEFORE requirePermission in a preHandler array so a disabled module answers the
|
||||||
|
* same way for every role — 403 with code "module_disabled" — and never reaches
|
||||||
|
* the permission/CSRF path. */
|
||||||
|
export function requireModule(db: Db, id: ModuleId) {
|
||||||
|
return async (_req: FastifyRequest, _reply: FastifyReply): Promise<void> => {
|
||||||
|
if (!effectiveModulesFor(db).includes(id)) {
|
||||||
|
throw Object.assign(new Error(`module disabled: ${id}`), { statusCode: 403, code: "module_disabled" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { deviceEvents, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { buildServer } from "../../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||||
|
|
||||||
|
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||||||
|
// settings → intake against an open parking session → done applies the sponsorship
|
||||||
|
// validation → bay payment settles the parking session at zero (what the exit reader
|
||||||
|
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
type Auth = { cookie: string; csrf: string };
|
||||||
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||||
|
|
||||||
|
async function admin(): Promise<Auth> {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An open transient session that has been parked long enough to owe money. */
|
||||||
|
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||||||
|
await makeLog(db).append({
|
||||||
|
type: "vehicle_entry",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
occurredAt: minutesAgo(enteredMinutesAgo),
|
||||||
|
payload: { sessionRef: identity, category: "default" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSettings(a: Auth) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: [{ name: "Car" }, { name: "SUV" }],
|
||||||
|
services: [{ name: "Standard" }, { name: "Inside" }],
|
||||||
|
prices: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const s = res.json();
|
||||||
|
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||||||
|
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||||||
|
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||||||
|
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||||||
|
const priced = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: {
|
||||||
|
categories: s.categories, services: s.services,
|
||||||
|
prices: [
|
||||||
|
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||||||
|
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||||||
|
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(priced.statusCode).toBe(200);
|
||||||
|
expect(priced.json().prices).toHaveLength(3);
|
||||||
|
return { car, suv, std, inside };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||||||
|
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||||||
|
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().payAt).toBe(payAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBeLessThan(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function events(a: Auth) {
|
||||||
|
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||||
|
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||||||
|
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||||||
|
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||||||
|
const bad = await app.inject({
|
||||||
|
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||||
|
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||||||
|
});
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||||||
|
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orders", () => {
|
||||||
|
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
const noSession = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
expect(noSession.statusCode).toBe(404);
|
||||||
|
|
||||||
|
await openSession("T-1");
|
||||||
|
const noPrice = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||||||
|
});
|
||||||
|
expect(noPrice.statusCode).toBe(409);
|
||||||
|
expect(noPrice.json().code).toBe("no_price");
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(201);
|
||||||
|
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||||||
|
|
||||||
|
await openSession("T-2");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||||||
|
|
||||||
|
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||||||
|
expect(chain).toHaveLength(2);
|
||||||
|
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-B");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||||
|
const s = look.json();
|
||||||
|
expect(s.chargeLines).toHaveLength(1);
|
||||||
|
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||||||
|
expect(s.chargesMinor).toBe(50000);
|
||||||
|
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||||||
|
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||||||
|
expect(pay.statusCode).toBeLessThan(300);
|
||||||
|
|
||||||
|
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||||||
|
expect(payment.payload.chargesMinor).toBe(50000);
|
||||||
|
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||||||
|
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||||||
|
|
||||||
|
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||||||
|
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||||||
|
expect(o.paidAt).toBeTruthy();
|
||||||
|
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||||||
|
expect(o.tender).toBe("cash");
|
||||||
|
// A second lookup no longer carries the line (it's settled).
|
||||||
|
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||||
|
expect(again.json().chargeLines).toEqual([]);
|
||||||
|
|
||||||
|
// The booth's Z-report: the wash money is inside cash (it is in the drawer) but
|
||||||
|
// OUT of the ticket bucket, under its own module — Bileta is parking money only.
|
||||||
|
const parking = payment.payload.parkingMinor as number;
|
||||||
|
const z = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||||
|
expect(z).toMatchObject({ till: "booth", cashTotalMinor: parking + 50000, ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||||
|
expect(z.ticketTotalMinor + z.subscriptionTotalMinor + 50000).toBe(z.cashTotalMinor + z.cardTotalMinor);
|
||||||
|
const summary = (await app.inject({ method: "GET", url: "/api/shifts", headers: { cookie: a.cookie } })).json().shifts[0];
|
||||||
|
expect(summary).toMatchObject({ till: "booth", ticketTotalMinor: parking, chargesByModuleMinor: { carwash: 50000 } });
|
||||||
|
const signed = (await events(a)).find((e) => e.type === "shift_z_report")!;
|
||||||
|
expect(signed.payload.chargesByModuleMinor).toEqual({ carwash: 50000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "comp");
|
||||||
|
await openSession("T-Y");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
expect(noShift.statusCode).toBe(409);
|
||||||
|
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||||||
|
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
expect(openWash.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
expect(done.statusCode).toBe(200);
|
||||||
|
expect(done.json().status).toBe("done");
|
||||||
|
expect(done.json().validationEventId).toBeTruthy();
|
||||||
|
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||||||
|
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||||
|
expect(mid.json().amountMinor).toBe(0);
|
||||||
|
expect(mid.json().paidAt).toBeNull();
|
||||||
|
|
||||||
|
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||||||
|
expect(paid.statusCode).toBe(200);
|
||||||
|
expect(paid.json().closed).toBe(true);
|
||||||
|
|
||||||
|
const evs = await events(a);
|
||||||
|
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||||||
|
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||||||
|
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||||||
|
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||||||
|
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||||||
|
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||||
|
expect(boothZ.till).toBe("booth");
|
||||||
|
expect(boothZ.cardTotalMinor).toBe(0);
|
||||||
|
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||||||
|
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||||||
|
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||||||
|
expect(parkingPay).toBeTruthy();
|
||||||
|
expect(parkingPay.payload.amountMinor).toBe(0);
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||||
|
expect(after.json().paidAt).toBeTruthy();
|
||||||
|
expect(after.json().withinGrace).toBe(true);
|
||||||
|
|
||||||
|
// The queue is empty (done + paid = closed).
|
||||||
|
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(queue.json().orders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "percent");
|
||||||
|
await openSession("T-P");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(s.paidAt).toBeNull();
|
||||||
|
expect(s.amountMinor).toBeGreaterThan(0);
|
||||||
|
expect(s.discountMinor).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "comp");
|
||||||
|
await openSession("T-V");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(before.validationLines).toHaveLength(1);
|
||||||
|
|
||||||
|
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||||||
|
expect(voided.statusCode).toBe(200);
|
||||||
|
expect(voided.json().status).toBe("void");
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.validationLines).toEqual([]);
|
||||||
|
expect(after.chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wash-only discount modes", () => {
|
||||||
|
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "doneTolerance", 15);
|
||||||
|
await openSession("T-D", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(before.amountMinor).toBe(20000);
|
||||||
|
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||||||
|
expect(v.payload.mode).toBe("timeCredit");
|
||||||
|
expect(v.payload.programMode).toBe("doneTolerance");
|
||||||
|
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||||||
|
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||||||
|
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.amountMinor).toBe(20000);
|
||||||
|
expect(after.discountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "doneTolerance", 120);
|
||||||
|
await openSession("T-D2", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.amountMinor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await seedSponsorship(a, "washPrice");
|
||||||
|
await openSession("T-W", 95);
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(after.discountMinor).toBe(50000);
|
||||||
|
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||||||
|
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||||||
|
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a merchant scan cannot apply a wash-only program", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
await seedSponsorship(a, "washPrice");
|
||||||
|
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||||||
|
await app.inject({
|
||||||
|
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||||||
|
});
|
||||||
|
await openSession("T-M");
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().error).toMatch(/car wash order/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module gate", () => {
|
||||||
|
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-G");
|
||||||
|
await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||||||
|
});
|
||||||
|
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||||||
|
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||||||
|
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||||
|
expect(q.statusCode).toBe(403);
|
||||||
|
expect(q.json().code).toBe("module_disabled");
|
||||||
|
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(look.chargeLines).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where the money is taken is a SITE setting", () => {
|
||||||
|
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||||||
|
await openSession("T-S1");
|
||||||
|
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||||
|
expect(o1.payAt).toBe("booth");
|
||||||
|
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||||||
|
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||||||
|
await openSession("T-S2");
|
||||||
|
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||||
|
expect(o2.payAt).toBe("bay");
|
||||||
|
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||||||
|
|
||||||
|
// A stale client insisting on the other place is refused, never silently overridden.
|
||||||
|
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||||||
|
expect(stale.statusCode).toBe(409);
|
||||||
|
expect(stale.json().code).toBe("pay_at_policy");
|
||||||
|
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tills are gated by the module permission", () => {
|
||||||
|
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
await seedSettings(a);
|
||||||
|
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||||||
|
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||||||
|
const washer = await seedUser(db, {
|
||||||
|
username: "lavazhier", roleId: "washer",
|
||||||
|
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||||
|
});
|
||||||
|
const w = await login(app, washer.username, washer.password);
|
||||||
|
// The desk's category/service pickers come from the settings read — the job has no
|
||||||
|
// site:read, so the module permission must open it (found on park dev, 2026-09-06).
|
||||||
|
const list = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: w.cookie } });
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
expect(list.json().categories.length).toBeGreaterThan(0);
|
||||||
|
expect((await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(w), payload: { payAt: "bay" } })).statusCode).toBe(403);
|
||||||
|
// What the UI offers: only the wash till.
|
||||||
|
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||||
|
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||||
|
// The booth's shift is refused outright (the role holds no shift:*).
|
||||||
|
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||||
|
expect(booth.statusCode).toBe(403);
|
||||||
|
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||||
|
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||||||
|
expect(boothState.statusCode).toBe(403);
|
||||||
|
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||||||
|
expect(boothCash.statusCode).toBe(403);
|
||||||
|
// The wash till works.
|
||||||
|
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||||||
|
expect(wash.statusCode).toBe(200);
|
||||||
|
expect(wash.json().till).toBe("carwash");
|
||||||
|
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||||
|
expect(washCash.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||||||
|
// sees the state and gets canWork=false; opening is refused.
|
||||||
|
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||||||
|
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||||||
|
|
||||||
|
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||||||
|
const booth1 = await seedUser(db, {
|
||||||
|
username: "boothie", roleId: "booth-op",
|
||||||
|
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||||
|
});
|
||||||
|
const b = await login(app, booth1.username, booth1.password);
|
||||||
|
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||||||
|
expect(noWash.statusCode).toBe(403);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a role reassignment takes effect without re-login", () => {
|
||||||
|
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db);
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-R");
|
||||||
|
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||||
|
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||||||
|
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||||||
|
const l = await login(app, looker.username, looker.password);
|
||||||
|
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(before.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||||||
|
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||||||
|
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||||||
|
expect(moved.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||||||
|
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||||
|
expect(after.statusCode).toBe(201);
|
||||||
|
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||||||
|
expect(me.roleId).toBe("wash-op");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a shift's activity log is per till", () => {
|
||||||
|
it("/api/events?till= applies tillOfEvent; a feed-only role reads its module's events and nothing else", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||||
|
const ids = await seedSettings(a);
|
||||||
|
await openSession("T-L");
|
||||||
|
await setPayAt(a, "bay");
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||||
|
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||||
|
const order = (await app.inject({
|
||||||
|
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||||
|
payload: { identity: "T-L", categoryId: ids.suv, serviceId: ids.std },
|
||||||
|
})).json();
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||||
|
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||||
|
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 500, till: "carwash" } });
|
||||||
|
await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(a), payload: { type: "cash_in", amountMinor: 700 } });
|
||||||
|
|
||||||
|
const types = async (qs: string, auth: Auth = a) => {
|
||||||
|
const r = await app.inject({ method: "GET", url: `/api/events?limit=200${qs}`, headers: { cookie: auth.cookie } });
|
||||||
|
expect(r.statusCode).toBe(200);
|
||||||
|
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).map((e) => `${e.type}${e.payload?.till ? `@${e.payload.till}` : ""}`);
|
||||||
|
};
|
||||||
|
// The wash till's log: its shift, its order (no money moved, but wash-desk activity),
|
||||||
|
// its bay payment and its voucher — none of the booth's.
|
||||||
|
const wash = await types("&till=carwash");
|
||||||
|
expect(wash).toEqual(expect.arrayContaining(["shift_open@carwash", "carwash_order", "carwash_payment@carwash", "cash_in@carwash"]));
|
||||||
|
expect(wash.some((t) => t.startsWith("vehicle_entry") || t === "shift_open@booth" || t === "cash_in@booth")).toBe(false);
|
||||||
|
// The booth's log: entry, its shift, its voucher — and no wash-desk activity.
|
||||||
|
const booth = await types("&till=booth");
|
||||||
|
expect(booth).toEqual(expect.arrayContaining(["vehicle_entry", "shift_open@booth", "cash_in@booth"]));
|
||||||
|
expect(booth.some((t) => t.startsWith("carwash_") || t.endsWith("@carwash"))).toBe(false);
|
||||||
|
// No till → everything (unchanged).
|
||||||
|
const all = await types("");
|
||||||
|
expect(all.length).toBe(wash.length + booth.length);
|
||||||
|
expect((await app.inject({ method: "GET", url: "/api/events?till=bar", headers: { cookie: a.cookie } })).statusCode).toBe(400);
|
||||||
|
|
||||||
|
// A wash operator holds carwash:read but not event:read: the log opens for them
|
||||||
|
// with ONLY the module's own event types (the live-socket rule, feedPermissionFor).
|
||||||
|
const washer = await seedUser(db, { username: "lavazhier", roleId: "washer", permissions: ["carwash:read", "carwash:cash"] });
|
||||||
|
const w = await login(app, washer.username, washer.password);
|
||||||
|
const mine = await types("&till=carwash", w);
|
||||||
|
expect(mine).toEqual(expect.arrayContaining(["carwash_order", "carwash_payment@carwash"]));
|
||||||
|
expect(mine.every((t) => t.startsWith("carwash_"))).toBe(true);
|
||||||
|
// A role with neither event:read nor any module feed permission reads nothing.
|
||||||
|
const clerk = await seedUser(db, { username: "clerk", roleId: "clerk", permissions: ["session:read"] });
|
||||||
|
const c = await login(app, clerk.username, clerk.password);
|
||||||
|
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).toMatchObject({ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { deviceEvents } from "../../device-events.js";
|
||||||
|
import type { ServerModule } from "../index.js";
|
||||||
|
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||||
|
import { carwashRoutes } from "./routes.js";
|
||||||
|
import { CarwashService } from "./service.js";
|
||||||
|
|
||||||
|
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
|
||||||
|
// module is lives in this folder: its service (master data, the order queue, the bay
|
||||||
|
// payment, the parking sponsorship + settlement), its routes, and the booth charge
|
||||||
|
// provider it registers with the core's PayStation. The core knows it only through the
|
||||||
|
// registry line in ../index.ts and the manifest in @parking/shared.
|
||||||
|
export const carwashModule: ServerModule = {
|
||||||
|
id: "carwash",
|
||||||
|
async register(app, deps) {
|
||||||
|
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
|
||||||
|
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
|
||||||
|
// queueing at all. One-way, background, never on the intake path.
|
||||||
|
const cfg = reviewUploadConfigFromEnv();
|
||||||
|
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||||
|
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||||
|
outbox.start();
|
||||||
|
// Entry-stream sampling: one in N entry vehicle reads goes to the reviewer as pure
|
||||||
|
// training material (the gate view, no order attached). The core announces the read;
|
||||||
|
// the module decides. Off unless CARWASH_REVIEW_ENTRY_SAMPLE is set.
|
||||||
|
const offVehicleRead = deviceEvents.onVehicleRead((e) => {
|
||||||
|
if (e.direction === "entry" && outbox.sampleEntry()) void outbox.enqueueEntry(e.read);
|
||||||
|
});
|
||||||
|
app.addHook("onClose", async () => offVehicleRead());
|
||||||
|
app.addHook("onClose", async () => outbox.stop());
|
||||||
|
const service = new CarwashService(deps, app.log, outbox);
|
||||||
|
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||||
|
// the core calls back after the payment is signed so the order is marked paid.
|
||||||
|
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||||
|
await carwashRoutes(app, deps, service, outbox);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { deviceEvents as deviceEventBus } from "../../device-events.js";
|
||||||
|
import { buildServer } from "../../server.js";
|
||||||
|
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||||
|
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||||
|
|
||||||
|
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||||||
|
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||||||
|
// backoff, never blocking the wash, never naming the site.
|
||||||
|
|
||||||
|
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||||||
|
async function frame(): Promise<Buffer> {
|
||||||
|
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||||||
|
.composite([
|
||||||
|
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||||||
|
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||||||
|
])
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||||||
|
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||||||
|
|
||||||
|
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||||||
|
* blurred plate drops far below 255 as the red bleeds in. */
|
||||||
|
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||||||
|
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||||||
|
return sum / (data.length / info.channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("makeReviewCrop", () => {
|
||||||
|
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||||||
|
const shot = await frame();
|
||||||
|
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||||||
|
expect(crop.plateBlurred).toBe(true);
|
||||||
|
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||||||
|
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||||||
|
expect(crop.width).toBeLessThanOrEqual(236);
|
||||||
|
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||||||
|
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||||||
|
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||||||
|
// frame is white, the review crop is the red bleeding in.
|
||||||
|
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||||||
|
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||||||
|
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||||||
|
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||||||
|
// Without a plate box: same crop, nothing blurred.
|
||||||
|
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||||||
|
expect(noPlate.plateBlurred).toBe(false);
|
||||||
|
// A big frame is capped to the max edge.
|
||||||
|
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||||||
|
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||||||
|
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("config + pseudonyms", () => {
|
||||||
|
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||||||
|
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||||||
|
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||||||
|
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||||||
|
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||||||
|
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||||||
|
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||||||
|
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("queue + drain", () => {
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
beforeEach(() => {
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
});
|
||||||
|
afterEach(() => close());
|
||||||
|
|
||||||
|
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60, entrySample: 0 };
|
||||||
|
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||||
|
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", categoryClasses: ["car", "sedan"], serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||||
|
|
||||||
|
async function seed(): Promise<void> {
|
||||||
|
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||||
|
db.insert(carwashOrders).values({
|
||||||
|
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||||||
|
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||||||
|
await seed();
|
||||||
|
const calls: { url: string; init: RequestInit }[] = [];
|
||||||
|
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
});
|
||||||
|
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||||
|
expect(await ob.enqueue(item, read)).toBe(true);
|
||||||
|
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row.status).toBe("queued");
|
||||||
|
expect(row.image!.length).toBeGreaterThan(500);
|
||||||
|
expect(row.payload).toMatchObject({ v: 1, kind: "wash", booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura", classes: ["car", "sedan"] }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||||
|
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||||
|
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(calls[0]!.url).toBe(cfg.url);
|
||||||
|
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||||||
|
const form = calls[0]!.init.body as FormData;
|
||||||
|
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||||||
|
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||||||
|
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(after.status).toBe("sent");
|
||||||
|
expect(after.image).toBeNull();
|
||||||
|
expect(after.sentAt).toBeTruthy();
|
||||||
|
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||||||
|
await seed();
|
||||||
|
let status = 503;
|
||||||
|
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||||||
|
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||||
|
await ob.enqueue(item, read);
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||||
|
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||||||
|
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||||||
|
// Not due yet → untouched.
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||||||
|
// Due again: a network error defers too; a 422 abandons.
|
||||||
|
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||||
|
status = 0;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||||
|
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||||
|
status = 422;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||||
|
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||||||
|
expect(row.image).toBeNull();
|
||||||
|
|
||||||
|
// A voided order is not a sample.
|
||||||
|
status = 200;
|
||||||
|
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||||||
|
db.update(carwashOrders).set({ status: "void" }).run();
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
// Expired items are abandoned without a request.
|
||||||
|
await ob.enqueue(item, read);
|
||||||
|
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||||||
|
db.update(carwashOrders).set({ status: "open" }).run();
|
||||||
|
const before = fetchFn.mock.calls.length;
|
||||||
|
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||||
|
expect(fetchFn.mock.calls.length).toBe(before);
|
||||||
|
expect(ob.status().failed).toBe(3);
|
||||||
|
|
||||||
|
// Entry sampling: one in N entry reads becomes a package with the crop and the
|
||||||
|
// camera's class only — no order, no operator, no category.
|
||||||
|
const sampler = new ReviewOutbox(db, silentLogger(), { ...cfg, entrySample: 3 }, fetchFn);
|
||||||
|
expect([sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry(), sampler.sampleEntry()]).toEqual([false, false, true, false]);
|
||||||
|
expect(ob.sampleEntry()).toBe(false); // entrySample 0 = off
|
||||||
|
expect(await sampler.enqueueEntry(read)).toBe(true);
|
||||||
|
const entryRow = db.select().from(carwashReviewOutbox).where(eq(carwashReviewOutbox.orderId, "entry:snap-1")).get()!;
|
||||||
|
expect(entryRow.payload).toMatchObject({ v: 1, kind: "entry", booth: "booth-7", vision: { class: "car", confidence: 0.86 }, image: { plateBlurred: true } });
|
||||||
|
expect(entryRow.payload).not.toHaveProperty("operator");
|
||||||
|
expect(entryRow.payload).not.toHaveProperty("operatorCategory");
|
||||||
|
expect(entryRow.image!.length).toBeGreaterThan(500);
|
||||||
|
|
||||||
|
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||||
|
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||||
|
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||||
|
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { eq } from "@parking/db";
|
||||||
|
|
||||||
|
describe("through the app", () => {
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
const saved = { ...process.env };
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||||
|
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||||
|
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||||
|
process.env.CARWASH_REVIEW_ENTRY_SAMPLE = "1";
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID", "CARWASH_REVIEW_ENTRY_SAMPLE"]) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
const a = await login(app, username, password);
|
||||||
|
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||||||
|
seedTariff(db);
|
||||||
|
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||||||
|
const cat = s.categories[0].id, svc = s.services[0].id;
|
||||||
|
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||||||
|
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||||||
|
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||||
|
db.insert(deviceEvents).values({
|
||||||
|
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||||||
|
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||||||
|
expect(order.statusCode).toBe(201);
|
||||||
|
// Enqueue is fire-and-forget: give the crop a moment.
|
||||||
|
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||||
|
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||||
|
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0, entrySample: 1 });
|
||||||
|
|
||||||
|
// An ENTRY vehicle read announced by the core (snapshot.ts) is sampled by the module
|
||||||
|
// (1 in 1 here) into an entry package; an exit read is not.
|
||||||
|
deviceEventBus.emitVehicleRead({ identity: "T-X", direction: "exit", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||||||
|
deviceEventBus.emitVehicleRead({ identity: "T-R", direction: "entry", read: { bodyType: "car", confidence: 0.8, snapshotId: "snap-r", box: CAR, plateBox: PLATE } });
|
||||||
|
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(2));
|
||||||
|
const rows = db.select().from(carwashReviewOutbox).all();
|
||||||
|
expect(rows.map((r) => (r.payload as { kind: string }).kind).sort()).toEqual(["entry", "wash"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
|
||||||
|
import type { NormBox, VehicleRead } from "@parking/shared";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
|
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
|
||||||
|
//
|
||||||
|
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
|
||||||
|
// the operator may err or cheat). So every wash order that has a vehicle read queues a
|
||||||
|
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
|
||||||
|
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
|
||||||
|
// The reviewer's verdict becomes the training label for the body-type classifier (phase
|
||||||
|
// B) and, per operator, the honest-mistake / fraud rate.
|
||||||
|
//
|
||||||
|
// Rules that shape this file:
|
||||||
|
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
|
||||||
|
// a background loop drains the queue when the private overlay (Netbird) is up, with
|
||||||
|
// backoff, and gives up loudly after EXPIRE_DAYS.
|
||||||
|
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
|
||||||
|
// signed ledger stays the only record of what happened at the wash.
|
||||||
|
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
|
||||||
|
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
|
||||||
|
// the operator as a keyed hash. The mapping back to people and places stays with the
|
||||||
|
// reviewer, off the collector.
|
||||||
|
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
|
||||||
|
// booth can do nothing at the collector but this one POST.
|
||||||
|
|
||||||
|
export interface ReviewUploadConfig {
|
||||||
|
/** The collector's ingest URL (reachable only over the overlay). */
|
||||||
|
readonly url: string;
|
||||||
|
/** Per-booth bearer token. */
|
||||||
|
readonly token: string;
|
||||||
|
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||||
|
readonly boothId: string;
|
||||||
|
readonly intervalSec: number;
|
||||||
|
/** Queue one in N ENTRY vehicle reads (no order attached) for the reviewer — the gate
|
||||||
|
* view is exactly what the classifier is trained on, and the entry stream is many times
|
||||||
|
* the wash stream. 0 = off. */
|
||||||
|
readonly entrySample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||||
|
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
|
||||||
|
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
|
||||||
|
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
|
||||||
|
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||||
|
if (!url || !token || !boothId) return null;
|
||||||
|
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
|
||||||
|
const sample = Number(env.CARWASH_REVIEW_ENTRY_SAMPLE ?? 0);
|
||||||
|
return {
|
||||||
|
url, token, boothId,
|
||||||
|
intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60,
|
||||||
|
entrySample: Number.isInteger(sample) && sample > 0 ? sample : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||||
|
* enough that a day of washes is a few megabytes. */
|
||||||
|
export const CROP_MAX_EDGE = 640;
|
||||||
|
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
|
||||||
|
const CROP_MARGIN = 0.08;
|
||||||
|
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
|
||||||
|
* should not resurface a fortnight of crops in one burst. */
|
||||||
|
export const EXPIRE_DAYS = 14;
|
||||||
|
/** Backoff: 1 min · 2^attempts, capped. */
|
||||||
|
const BACKOFF_BASE_MS = 60_000;
|
||||||
|
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const UPLOAD_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** What one order contributes to the package (the service hands this over at intake). */
|
||||||
|
export interface ReviewItemInput {
|
||||||
|
readonly orderId: string;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly createdBy: string;
|
||||||
|
readonly categoryId: string;
|
||||||
|
readonly categoryName: string;
|
||||||
|
/** The vision classes the chosen category covers at this site (its mapping) — lets the
|
||||||
|
* reviewer's class be judged against the operator's category without the site's setup. */
|
||||||
|
readonly categoryClasses: readonly string[];
|
||||||
|
readonly serviceName: string;
|
||||||
|
readonly visionCategoryId: string | null;
|
||||||
|
readonly downgraded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
|
||||||
|
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
|
||||||
|
*/
|
||||||
|
export async function makeReviewCrop(
|
||||||
|
snapshotBytes: Buffer,
|
||||||
|
box: NormBox,
|
||||||
|
plateBox: NormBox | null | undefined,
|
||||||
|
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
|
||||||
|
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
|
||||||
|
const meta = await img.metadata();
|
||||||
|
const W = meta.width ?? 0;
|
||||||
|
const H = meta.height ?? 0;
|
||||||
|
if (!W || !H) throw new Error("snapshot has no dimensions");
|
||||||
|
const px = (b: NormBox) => ({
|
||||||
|
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
|
||||||
|
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
|
||||||
|
});
|
||||||
|
const v = px(box);
|
||||||
|
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
|
||||||
|
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
|
||||||
|
const left = Math.max(0, v.left - mw);
|
||||||
|
const top = Math.max(0, v.top - mh);
|
||||||
|
const right = Math.min(W, v.right + mw);
|
||||||
|
const bottom = Math.min(H, v.bottom + mh);
|
||||||
|
const width = right - left;
|
||||||
|
const height = bottom - top;
|
||||||
|
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
|
||||||
|
|
||||||
|
let crop = img.clone().extract({ left, top, width, height });
|
||||||
|
let plateBlurred = false;
|
||||||
|
if (plateBox) {
|
||||||
|
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
|
||||||
|
const p = px(plateBox);
|
||||||
|
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
|
||||||
|
const pl = Math.max(0, p.left - pad - left);
|
||||||
|
const pt = Math.max(0, p.top - pad - top);
|
||||||
|
const pr = Math.min(width, p.right + pad - left);
|
||||||
|
const pb = Math.min(height, p.bottom + pad - top);
|
||||||
|
if (pr - pl >= 2 && pb - pt >= 2) {
|
||||||
|
const region = await sharp(await crop.clone().toBuffer())
|
||||||
|
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
|
||||||
|
.blur(Math.max(6, Math.round((pr - pl) / 6)))
|
||||||
|
.toBuffer();
|
||||||
|
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
|
||||||
|
plateBlurred = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = await crop
|
||||||
|
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 85, mozjpeg: true })
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
|
||||||
|
* meaningless anywhere else. */
|
||||||
|
export function operatorRef(boothId: string, username: string): string {
|
||||||
|
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface OutboxStatus {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly boothId: string | null;
|
||||||
|
readonly queued: number;
|
||||||
|
readonly sent: number;
|
||||||
|
readonly failed: number;
|
||||||
|
readonly lastSentAt: string | null;
|
||||||
|
readonly lastError: string | null;
|
||||||
|
/** 0 = entry sampling off; N = one in N entry reads is queued. */
|
||||||
|
readonly entrySample: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewOutbox {
|
||||||
|
readonly #db: Db;
|
||||||
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #cfg: ReviewUploadConfig | null;
|
||||||
|
readonly #fetch: FetchLike;
|
||||||
|
#timer: NodeJS.Timeout | null = null;
|
||||||
|
#draining = false;
|
||||||
|
#entrySeen = 0;
|
||||||
|
|
||||||
|
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||||
|
this.#db = db;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#cfg = cfg;
|
||||||
|
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this.#cfg != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
|
||||||
|
* intake path; every failure is logged, none is thrown. Skipped when there is no
|
||||||
|
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
|
||||||
|
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||||
|
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||||
|
if (!this.#cfg) return false;
|
||||||
|
return this.#queue(item.orderId, read, (id, crop) => ({
|
||||||
|
v: 1,
|
||||||
|
kind: "wash",
|
||||||
|
booth: this.#cfg!.boothId,
|
||||||
|
item: id,
|
||||||
|
order: item.orderId,
|
||||||
|
at: item.createdAt,
|
||||||
|
operator: operatorRef(this.#cfg!.boothId, item.createdBy),
|
||||||
|
operatorCategory: { id: item.categoryId, name: item.categoryName, classes: [...item.categoryClasses] },
|
||||||
|
service: item.serviceName,
|
||||||
|
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||||
|
downgraded: item.downgraded,
|
||||||
|
image: crop,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every Nth entry read is a sample (N = entrySample); the caller queues it. Counted
|
||||||
|
* in-process, so "1 in 5" is exactly that across a booth's day. */
|
||||||
|
sampleEntry(): boolean {
|
||||||
|
const n = this.#cfg?.entrySample ?? 0;
|
||||||
|
if (n <= 0) return false;
|
||||||
|
this.#entrySeen += 1;
|
||||||
|
return this.#entrySeen % n === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue an ENTRY sample: the crop and the camera's class only — no order, no operator,
|
||||||
|
* no category. Pure training material in the gate view; the reviewer labels it. */
|
||||||
|
async enqueueEntry(read: VehicleRead): Promise<boolean> {
|
||||||
|
if (!this.#cfg) return false;
|
||||||
|
return this.#queue(`entry:${read.snapshotId ?? "?"}`, read, (id, crop) => ({
|
||||||
|
v: 1,
|
||||||
|
kind: "entry",
|
||||||
|
booth: this.#cfg!.boothId,
|
||||||
|
item: id,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
vision: { class: read.bodyType, confidence: read.confidence },
|
||||||
|
image: crop,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #queue(
|
||||||
|
ref: string,
|
||||||
|
read: VehicleRead,
|
||||||
|
build: (id: string, image: { width: number; height: number; plateBlurred: boolean }) => Record<string, unknown>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!read.box || !read.snapshotId) return false;
|
||||||
|
try {
|
||||||
|
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||||
|
if (!snap) {
|
||||||
|
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — ${ref} not queued`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
|
||||||
|
const id = randomUUID();
|
||||||
|
const payload = build(id, { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred });
|
||||||
|
this.#db
|
||||||
|
.insert(carwashReviewOutbox)
|
||||||
|
.values({ id, orderId: ref, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||||
|
.run();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.warn(`carwash review: could not queue ${ref}: ${(err as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (!this.#cfg || this.#timer) return;
|
||||||
|
const tick = () => {
|
||||||
|
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
|
||||||
|
};
|
||||||
|
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
|
||||||
|
this.#timer.unref?.();
|
||||||
|
setTimeout(tick, 5_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.#timer) clearInterval(this.#timer);
|
||||||
|
this.#timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
|
||||||
|
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
|
||||||
|
const tally = { sent: 0, failed: 0, deferred: 0 };
|
||||||
|
if (!this.#cfg || this.#draining) return tally;
|
||||||
|
this.#draining = true;
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const due = this.#db
|
||||||
|
.select()
|
||||||
|
.from(carwashReviewOutbox)
|
||||||
|
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
|
||||||
|
.orderBy(asc(carwashReviewOutbox.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
|
for (const row of due) {
|
||||||
|
const outcome = await this.#send(row);
|
||||||
|
tally[outcome] += 1;
|
||||||
|
}
|
||||||
|
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
|
||||||
|
} finally {
|
||||||
|
this.#draining = false;
|
||||||
|
}
|
||||||
|
return tally;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
|
||||||
|
const cfg = this.#cfg!;
|
||||||
|
const ageMs = Date.now() - Date.parse(row.createdAt);
|
||||||
|
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
|
||||||
|
// A wash voided before delivery is not a sample (and not a decision to review).
|
||||||
|
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
|
||||||
|
if (order?.status === "void") return this.#fail(row, "order voided");
|
||||||
|
if (!row.image) return this.#fail(row, "image missing");
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("meta", JSON.stringify(row.payload));
|
||||||
|
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await this.#fetch(cfg.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
|
||||||
|
body: form,
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
return "sent";
|
||||||
|
}
|
||||||
|
// The collector refused the package itself → no retry will help.
|
||||||
|
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
|
||||||
|
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
|
||||||
|
return this.#defer(row, `HTTP ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
|
||||||
|
const attempts = row.attempts + 1;
|
||||||
|
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
|
||||||
|
this.#db
|
||||||
|
.update(carwashReviewOutbox)
|
||||||
|
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
|
||||||
|
.where(eq(carwashReviewOutbox.id, row.id))
|
||||||
|
.run();
|
||||||
|
return "deferred";
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): OutboxStatus {
|
||||||
|
const count = (s: "queued" | "sent" | "failed") =>
|
||||||
|
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
|
||||||
|
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
|
||||||
|
const lastErr = this.#db
|
||||||
|
.select({ e: carwashReviewOutbox.lastError })
|
||||||
|
.from(carwashReviewOutbox)
|
||||||
|
.where(sql`${carwashReviewOutbox.lastError} is not null`)
|
||||||
|
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
|
||||||
|
.limit(1)
|
||||||
|
.get()?.e ?? null;
|
||||||
|
return {
|
||||||
|
enabled: this.enabled,
|
||||||
|
boothId: this.#cfg?.boothId ?? null,
|
||||||
|
entrySample: this.#cfg?.entrySample ?? 0,
|
||||||
|
queued: count("queued"),
|
||||||
|
sent: count("sent"),
|
||||||
|
failed: count("failed"),
|
||||||
|
lastSentAt: lastSent,
|
||||||
|
lastError: lastErr,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
|
import type { Tender } from "@parking/shared";
|
||||||
|
import { requireAnyPermission, requirePermission } from "../../auth.js";
|
||||||
|
import { requireModule } from "../../modules.js";
|
||||||
|
import { NoShiftOpenError } from "../../shift-service.js";
|
||||||
|
import type { ServerModuleDeps } from "../index.js";
|
||||||
|
import type { ReviewOutbox } from "./review-outbox.js";
|
||||||
|
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||||
|
|
||||||
|
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||||
|
// FIRST (403 module_disabled), then a permission:
|
||||||
|
// settings (master data) site:read / site:update — the site admin's job
|
||||||
|
// queue / ticket lookup carwash:read — the wash desk
|
||||||
|
// intake carwash:create
|
||||||
|
// done / bay payment / void carwash:update
|
||||||
|
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
|
||||||
|
// composed through the existing /api/validation/programs/:id route (site:update).
|
||||||
|
|
||||||
|
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||||
|
if (err instanceof CarwashError) {
|
||||||
|
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
||||||
|
}
|
||||||
|
if (err instanceof NoShiftOpenError) {
|
||||||
|
// The bay takes money on the CARWASH till: the wash operator's own shift must be
|
||||||
|
// open (the booth's does not count). The desk shows its shift control on this code.
|
||||||
|
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
|
||||||
|
const moduleOn = requireModule(deps.db, "carwash");
|
||||||
|
// The price list is the desk's working data as much as Setup's: the wash operator
|
||||||
|
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
||||||
|
const settingsRead = [moduleOn, requireAnyPermission("carwash:read", "site:read")];
|
||||||
|
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||||
|
const read = [moduleOn, requirePermission("carwash:read")];
|
||||||
|
const create = [moduleOn, requirePermission("carwash:create")];
|
||||||
|
const update = [moduleOn, requirePermission("carwash:update")];
|
||||||
|
|
||||||
|
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||||
|
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||||
|
// reviewer, how many went, the last error. Site admin's read.
|
||||||
|
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||||
|
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null, entrySample: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.saveSettings(req.body ?? {}, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
|
||||||
|
service.lookup(req.params.identity),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
|
||||||
|
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
|
||||||
|
return { orders: service.openOrders() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
|
||||||
|
"/api/carwash/orders",
|
||||||
|
{ preHandler: create },
|
||||||
|
async (req, reply) => {
|
||||||
|
const b = req.body ?? {};
|
||||||
|
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
|
||||||
|
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
|
||||||
|
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
|
||||||
|
try {
|
||||||
|
const order = await service.createOrder({
|
||||||
|
identity: String(b.identity ?? ""),
|
||||||
|
categoryId: String(b.categoryId ?? ""),
|
||||||
|
serviceId: String(b.serviceId ?? ""),
|
||||||
|
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
|
||||||
|
actor: req.user.username,
|
||||||
|
});
|
||||||
|
return reply.code(201).send(order);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.markDone(req.params.id, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
|
||||||
|
"/api/carwash/orders/:id/pay",
|
||||||
|
{ preHandler: update },
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
|
||||||
|
"/api/carwash/orders/:id/void",
|
||||||
|
{ preHandler: update },
|
||||||
|
async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(reply, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Db } from "@parking/db";
|
||||||
|
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import type { PayStation } from "../pay-station.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
|
import { carwashModule } from "./carwash/index.js";
|
||||||
|
import { validationModule } from "./validation/index.js";
|
||||||
|
|
||||||
|
// The server-side module registry. A module's routes live in its own folder
|
||||||
|
// (apps/server/src/modules/<id>/index.ts) and are registered by iterating
|
||||||
|
// @parking/shared's MODULES — so adding a module is one manifest entry + one folder +
|
||||||
|
// one line in SERVER_MODULES below, with nothing else in the core touched
|
||||||
|
// (wiki/decisions/venue-modules.md, "A module = a manifest + three folders").
|
||||||
|
//
|
||||||
|
// `parking` is registered in the manifest but has NO folder yet: its routes are still
|
||||||
|
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
|
||||||
|
// across it subsystem by subsystem as each is touched, not in one big move.
|
||||||
|
|
||||||
|
/** What the core hands a module at registration. Modules reach the core ONLY through
|
||||||
|
* these (never by importing another module): the DB, the signed ledger, the booth
|
||||||
|
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
|
||||||
|
* quote, pay) and the shift service (money needs an open shift). */
|
||||||
|
export interface ServerModuleDeps {
|
||||||
|
db: Db;
|
||||||
|
eventLog: EventLog;
|
||||||
|
payStation: PayStation;
|
||||||
|
shiftService: ShiftService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerModule {
|
||||||
|
id: ModuleId;
|
||||||
|
register(app: FastifyInstance, deps: ServerModuleDeps): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
|
||||||
|
validation: validationModule,
|
||||||
|
carwash: carwashModule,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Register every folder-based module in registry order, then log what this site
|
||||||
|
* is entitled to / has effective, so a "why is X missing" question is answerable
|
||||||
|
* from the container log alone. */
|
||||||
|
export async function registerModules(app: FastifyInstance, deps: ServerModuleDeps): Promise<void> {
|
||||||
|
for (const manifest of MODULES) {
|
||||||
|
const impl = SERVER_MODULES[manifest.id];
|
||||||
|
if (impl) {
|
||||||
|
if (impl.id !== manifest.id) throw new Error(`module registry mismatch: ${impl.id} registered under ${manifest.id}`);
|
||||||
|
await impl.register(app, deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { entitled, unknown } = parseEntitledModules(process.env.MODULES_ENTITLED);
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
app.log.warn({ unknown }, "MODULES_ENTITLED names unknown module ids — ignored");
|
||||||
|
}
|
||||||
|
app.log.info(
|
||||||
|
{ entitled, effective: effectiveModulesFor(deps.db) },
|
||||||
|
"venue modules (entitled = MODULES_ENTITLED env; effective = entitled ∩ site activation)",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { validationRoutes } from "../../routes/validations.js";
|
||||||
|
import type { ServerModule } from "../index.js";
|
||||||
|
|
||||||
|
// Merchant-scan ticket validation as a venue module. Kept for the Bar until a Bar
|
||||||
|
// module absorbs it (wiki/decisions/venue-modules.md, decision 1). The routes
|
||||||
|
// themselves still live in routes/validations.ts (unchanged location, now guarded by
|
||||||
|
// requireModule("validation")); this folder is the registry hook.
|
||||||
|
export const validationModule: ServerModule = {
|
||||||
|
id: "validation",
|
||||||
|
async register(app, { db, eventLog }) {
|
||||||
|
await validationRoutes(app, db, eventLog);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
import { BOOTH_TILL, priceSession, type ChargeLine, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
@@ -29,6 +29,18 @@ export class NoTariffError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module that folds its own charges into a booth settlement (venue-modules.md):
|
||||||
|
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
|
||||||
|
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
|
||||||
|
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
|
||||||
|
* never imports a module.
|
||||||
|
*/
|
||||||
|
export interface ChargeProvider {
|
||||||
|
lines(identity: string): ChargeLine[];
|
||||||
|
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Quote {
|
export interface Quote {
|
||||||
readonly identity: string;
|
readonly identity: string;
|
||||||
/** Vehicle entry time (the session's original entry; for display/audit). */
|
/** Vehicle entry time (the session's original entry; for display/audit). */
|
||||||
@@ -39,8 +51,14 @@ export interface Quote {
|
|||||||
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
|
||||||
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
* "full stay minus paid" (which a daily cap collapses toward zero). */
|
||||||
readonly periodStart: string;
|
readonly periodStart: string;
|
||||||
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
|
/** Amount owed now: the parking fee for [periodStart → now] NET of merchant
|
||||||
|
* validations, PLUS any module charge lines (a wash paid at the booth). */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
|
/** The parking-only net (amountMinor − chargesMinor). */
|
||||||
|
readonly parkingMinor: number;
|
||||||
|
/** Non-parking charges folded in by modules (see ChargeProvider). */
|
||||||
|
readonly chargeLines: ChargeLine[];
|
||||||
|
readonly chargesMinor: number;
|
||||||
/** The pre-validation fee (= amountMinor when no validations apply). */
|
/** The pre-validation fee (= amountMinor when no validations apply). */
|
||||||
readonly grossMinor: number;
|
readonly grossMinor: number;
|
||||||
/** Total the merchant validations took off (gross − net). */
|
/** Total the merchant validations took off (gross − net). */
|
||||||
@@ -133,12 +151,16 @@ export interface SessionLookup {
|
|||||||
readonly grossMinor: number | null;
|
readonly grossMinor: number | null;
|
||||||
readonly discountMinor: number | null;
|
readonly discountMinor: number | null;
|
||||||
readonly validationLines: ValidationLine[];
|
readonly validationLines: ValidationLine[];
|
||||||
|
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
|
||||||
|
readonly chargeLines: ChargeLine[];
|
||||||
|
readonly chargesMinor: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayStation {
|
export class PayStation {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
readonly #logger: FastifyBaseLogger;
|
readonly #logger: FastifyBaseLogger;
|
||||||
|
readonly #chargeProviders: ChargeProvider[] = [];
|
||||||
|
|
||||||
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
@@ -146,6 +168,30 @@ export class PayStation {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
|
||||||
|
registerChargeProvider(p: ChargeProvider): void {
|
||||||
|
this.#chargeProviders.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The currency of the tariff in force right now (null = none published). Modules
|
||||||
|
* price their own goods in the same money the booth takes. */
|
||||||
|
activeCurrency(): string | null {
|
||||||
|
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chargeLines(identity: string): ChargeLine[] {
|
||||||
|
const out: ChargeLine[] = [];
|
||||||
|
for (const p of this.#chargeProviders) {
|
||||||
|
try {
|
||||||
|
out.push(...p.lines(identity));
|
||||||
|
} catch (err) {
|
||||||
|
// A module's fault must never block a parking settlement — log and price without it.
|
||||||
|
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
|
||||||
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
|
||||||
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
|
||||||
@@ -184,11 +230,16 @@ export class PayStation {
|
|||||||
category,
|
category,
|
||||||
validations,
|
validations,
|
||||||
);
|
);
|
||||||
|
const chargeLines = this.#chargeLines(identity);
|
||||||
|
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
enteredAt: entry.occurredAt,
|
enteredAt: entry.occurredAt,
|
||||||
periodStart: p.periodStart,
|
periodStart: p.periodStart,
|
||||||
amountMinor: p.amountMinor,
|
amountMinor: p.amountMinor + chargesMinor,
|
||||||
|
parkingMinor: p.amountMinor,
|
||||||
|
chargeLines,
|
||||||
|
chargesMinor,
|
||||||
grossMinor: p.grossMinor,
|
grossMinor: p.grossMinor,
|
||||||
discountMinor: p.discountMinor,
|
discountMinor: p.discountMinor,
|
||||||
validationLines: p.validationLines,
|
validationLines: p.validationLines,
|
||||||
@@ -246,6 +297,7 @@ export class PayStation {
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency: subWindow.currency ?? undefined,
|
currency: subWindow.currency ?? undefined,
|
||||||
tender,
|
tender,
|
||||||
|
till: BOOTH_TILL,
|
||||||
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
|
||||||
subscriptionWindowCharge: true,
|
subscriptionWindowCharge: true,
|
||||||
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
|
||||||
@@ -258,7 +310,7 @@ export class PayStation {
|
|||||||
const q = this.quote(identity);
|
const q = this.quote(identity);
|
||||||
const amountMinor = overrideMinor ?? q.amountMinor;
|
const amountMinor = overrideMinor ?? q.amountMinor;
|
||||||
|
|
||||||
await this.#log.append({
|
const paymentEvent = await this.#log.append({
|
||||||
type: "payment",
|
type: "payment",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity,
|
identity,
|
||||||
@@ -267,7 +319,19 @@ export class PayStation {
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency: q.currency,
|
currency: q.currency,
|
||||||
tender,
|
tender,
|
||||||
|
// Parking money is BOOTH money (a wash paid at the booth rides along as
|
||||||
|
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
|
||||||
|
till: BOOTH_TILL,
|
||||||
tariffVersionId: q.tariffVersionId,
|
tariffVersionId: q.tariffVersionId,
|
||||||
|
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
|
||||||
|
// receipt reproduces and reporting can split parking from the rest.
|
||||||
|
...(q.chargeLines.length
|
||||||
|
? {
|
||||||
|
chargeLines: q.chargeLines.map((l) => ({ ...l })),
|
||||||
|
chargesMinor: q.chargesMinor,
|
||||||
|
parkingMinor: q.parkingMinor,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
// The exit flow reads graceExitMin off the payment to validate the
|
// The exit flow reads graceExitMin off the payment to validate the
|
||||||
// walk-back window without re-resolving the tariff.
|
// walk-back window without re-resolving the tariff.
|
||||||
graceExitMin: q.graceExitMin,
|
graceExitMin: q.graceExitMin,
|
||||||
@@ -294,6 +358,17 @@ export class PayStation {
|
|||||||
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let each module mark the charge lines it contributed as settled by this payment.
|
||||||
|
if (q.chargeLines.length) {
|
||||||
|
for (const p of this.#chargeProviders) {
|
||||||
|
try {
|
||||||
|
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
|
||||||
return { amountMinor, currency: q.currency };
|
return { amountMinor, currency: q.currency };
|
||||||
}
|
}
|
||||||
@@ -320,6 +395,7 @@ export class PayStation {
|
|||||||
withinGrace: false, graceExpiresAt: null,
|
withinGrace: false, graceExpiresAt: null,
|
||||||
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
|
||||||
grossMinor: null, discountMinor: null, validationLines: [],
|
grossMinor: null, discountMinor: null, validationLines: [],
|
||||||
|
chargeLines: [], chargesMinor: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
// Subscription occurrence? The entry payload carries permit:true + permitId.
|
||||||
@@ -360,6 +436,8 @@ export class PayStation {
|
|||||||
let grossMinor: number | null = null;
|
let grossMinor: number | null = null;
|
||||||
let discountMinor: number | null = null;
|
let discountMinor: number | null = null;
|
||||||
let validationLines: ValidationLine[] = [];
|
let validationLines: ValidationLine[] = [];
|
||||||
|
let chargeLines: ChargeLine[] = [];
|
||||||
|
let chargesMinor: number | null = null;
|
||||||
if (open && !isSubscription) {
|
if (open && !isSubscription) {
|
||||||
try {
|
try {
|
||||||
const q = this.quote(id);
|
const q = this.quote(id);
|
||||||
@@ -368,6 +446,8 @@ export class PayStation {
|
|||||||
grossMinor = q.grossMinor;
|
grossMinor = q.grossMinor;
|
||||||
discountMinor = q.discountMinor;
|
discountMinor = q.discountMinor;
|
||||||
validationLines = q.validationLines;
|
validationLines = q.validationLines;
|
||||||
|
chargeLines = q.chargeLines;
|
||||||
|
chargesMinor = q.chargesMinor;
|
||||||
} catch {
|
} catch {
|
||||||
/* no active tariff — leave null; modal shows session without a price */
|
/* no active tariff — leave null; modal shows session without a price */
|
||||||
}
|
}
|
||||||
@@ -389,6 +469,7 @@ export class PayStation {
|
|||||||
subscriptionHolder: this.#holderOf(subscriptionId),
|
subscriptionHolder: this.#holderOf(subscriptionId),
|
||||||
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
plate: plateForIdentity(this.#db, id)?.plate ?? null,
|
||||||
grossMinor, discountMinor, validationLines,
|
grossMinor, discountMinor, validationLines,
|
||||||
|
chargeLines, chargesMinor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { isNormBox, 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,38 @@ interface ReadDetail {
|
|||||||
plate?: string;
|
plate?: string;
|
||||||
confidence?: number;
|
confidence?: number;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
|
bodyType?: string;
|
||||||
|
bodyConfidence?: number;
|
||||||
|
snapshotId?: string;
|
||||||
|
vehicleBox?: unknown;
|
||||||
|
plateBox?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
|
||||||
|
plateBox: isNormBox(d.plateBox) ? d.plateBox : 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. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, roles, users, type Db } from "@parking/db";
|
import { eq, roles, users, type Db } from "@parking/db";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
import {
|
import {
|
||||||
clearAuthCookies,
|
clearAuthCookies,
|
||||||
newCsrfToken,
|
newCsrfToken,
|
||||||
@@ -107,6 +108,9 @@ function sessionView(
|
|||||||
fontScale: user.fontScale,
|
fontScale: user.fontScale,
|
||||||
fullName: user.fullName ?? null,
|
fullName: user.fullName ?? null,
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
|
// Effective venue modules (entitled ∩ activated) so the SPA can hide nav/routes
|
||||||
|
// on first paint. The server still enforces via requireModule — this is display.
|
||||||
|
modules: effectiveModulesFor(db),
|
||||||
...(csrf ? { csrfToken: csrf } : {}),
|
...(csrf ? { csrfToken: csrf } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import type { Db } from "@parking/db";
|
||||||
|
import type { TillId } from "@parking/shared";
|
||||||
|
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||||
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
|
||||||
|
|
||||||
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
|
||||||
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
// receipt/disbursement FREELY (no admin sign-off at creation); an admin REVIEWS it after
|
||||||
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
// the fact (authorize/deny — a flag that never moves cash). See wiki/concepts/shift.md.
|
||||||
// - POST /api/drawer/movement : operator records a cash_in/cash_out. (drawer:create)
|
// - POST /api/drawer/movement : operator records a cash_in/cash_out on a till.
|
||||||
// - GET /api/drawer/movements: list with review status. Operators see (shift:read)
|
// Guard = the till's `cash` (booth drawer:create,
|
||||||
// only their own; reviewers see all + can filter status.
|
// wash carwash:cash).
|
||||||
|
// - GET /api/drawer/movements: list with review status, over the tills the role may
|
||||||
|
// read (own movements); reviewers (drawer:review) see all
|
||||||
|
// tills + all operators and can filter status.
|
||||||
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
// - POST /api/drawer/review : admin authorize/deny a movement. (drawer:review)
|
||||||
// - GET /api/drawer/balance : the physical drawer balance NOW (cash (shift:read)
|
// - GET /api/drawer/balance : a till's physical balance NOW (guard = the till's read).
|
||||||
// payments + vouchers over the whole chain — the
|
|
||||||
// amount that carries across shifts).
|
|
||||||
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
|
||||||
// judgment about the operator settled outside the app, never a cash reversal.
|
// judgment about the operator settled outside the app, never a cash reversal.
|
||||||
|
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); each
|
||||||
|
// desk's cash is guarded by that desk's own permissions (venue-modules.md §"Permissions
|
||||||
|
// matrix").
|
||||||
|
|
||||||
interface MovementBody {
|
interface MovementBody {
|
||||||
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
|
||||||
@@ -23,6 +30,8 @@ interface MovementBody {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
/** Which drawer (default: the booth). */
|
||||||
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewBody {
|
interface ReviewBody {
|
||||||
@@ -36,15 +45,15 @@ interface ReviewBody {
|
|||||||
interface MovementsQuery {
|
interface MovementsQuery {
|
||||||
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
|
||||||
status?: MovementStatus;
|
status?: MovementStatus;
|
||||||
|
/** Filter to one till; absent = every till the role may read (reviewers: every till). */
|
||||||
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||||
const createGuard = requirePermission("drawer:create");
|
|
||||||
const reviewGuard = requirePermission("drawer:review");
|
const reviewGuard = requirePermission("drawer:review");
|
||||||
const readGuard = requirePermission("shift:read");
|
|
||||||
|
|
||||||
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
// Operator RECORDS a movement — freely, no authorizer. It counts in the drawer at once.
|
||||||
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: createGuard }, async (req, reply) => {
|
app.post<{ Body: MovementBody }>("/api/drawer/movement", { preHandler: requireTill(db, "cash", "body") }, async (req, reply) => {
|
||||||
const b = req.body ?? ({} as MovementBody);
|
const b = req.body ?? ({} as MovementBody);
|
||||||
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
if (b.type !== "cash_in" && b.type !== "cash_out") {
|
||||||
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
|
||||||
@@ -56,6 +65,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
|||||||
amountMinor: b.amountMinor,
|
amountMinor: b.amountMinor,
|
||||||
reason: b.reason ?? "",
|
reason: b.reason ?? "",
|
||||||
currency: b.currency,
|
currency: b.currency,
|
||||||
|
till: req.till!,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
|
||||||
@@ -63,22 +73,37 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// List movements + review status. Operators are hard-scoped to their OWN movements; a
|
// List movements + review status. Operators are hard-scoped to their OWN movements on
|
||||||
// reviewer sees ALL and may filter by status (the pending review queue).
|
// the tills they may read; a reviewer sees ALL and may filter by status (the pending
|
||||||
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
|
// review queue).
|
||||||
|
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
|
||||||
|
const readable = tillsReadableBy(db, req.user.roleId);
|
||||||
|
if (!canReview && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const q = req.query ?? {};
|
const q = req.query ?? {};
|
||||||
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
|
||||||
const movements = shift.movementsWithStatus({
|
let tills: TillId[] | undefined = canReview ? undefined : readable;
|
||||||
operator: canReview ? undefined : req.user.username,
|
if (q.till?.trim()) {
|
||||||
status,
|
const parsed = parseTill(db, q.till.trim());
|
||||||
});
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
if (!canReview && !readable.includes(parsed)) {
|
||||||
|
return reply.code(403).send({ error: `your role cannot see the ${parsed} till`, code: "till_forbidden", till: parsed });
|
||||||
|
}
|
||||||
|
tills = [parsed];
|
||||||
|
}
|
||||||
|
const operator = canReview ? undefined : req.user.username;
|
||||||
|
const movements = tills
|
||||||
|
? tills.flatMap((till) => shift.movementsWithStatus({ operator, status, till })).sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||||
|
: shift.movementsWithStatus({ operator, status });
|
||||||
return { movements, scope: canReview ? "all" : "self" };
|
return { movements, scope: canReview ? "all" : "self" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// The physical drawer balance now. Same visibility as the open shift's X-report
|
// A till's physical drawer balance now. Same visibility as the open shift's X-report
|
||||||
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
|
// (the till's read guard) — a drawer is a shared till, not per-operator data.
|
||||||
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
|
app.get("/api/drawer/balance", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||||
|
till: req.till!,
|
||||||
|
...shift.drawerBalance(req.till!),
|
||||||
|
}));
|
||||||
|
|
||||||
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
|
||||||
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, desc, gte, lte, ledgerEvents, type Db } from "@parking/db";
|
import { and, desc, gte, inArray, lte, sql, ledgerEvents, type Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import { BOOTH_TILL, MODULES, feedPermissionFor, isTillId, type LedgerEvent, type LedgerEventType } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requireAuth, requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
import { enrichEvents } from "../event-enrich.js";
|
import { enrichEvents } from "../event-enrich.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
|
||||||
@@ -15,25 +16,59 @@ export async function eventRoutes(
|
|||||||
db: Db,
|
db: Db,
|
||||||
eventLog: EventLog,
|
eventLog: EventLog,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Reading the log (the audit trail).
|
// Reading the log (the audit trail). `event:read` reads everything; a role WITHOUT it
|
||||||
const guard = requirePermission("event:read");
|
// may still hold a module's feed permission (a wash operator's `carwash:read`) and
|
||||||
|
// then reads ONLY that module's event types — the same rule the live socket applies
|
||||||
|
// (feedPermissionFor; venue-modules.md §Permissions matrix, move 3).
|
||||||
|
|
||||||
|
/** The event types a role may read, or null for "everything" (event:read). Empty =
|
||||||
|
* the role reads nothing → 403 at the route. */
|
||||||
|
function readableTypes(roleId: string): LedgerEventType[] | null {
|
||||||
|
if (roleHasPermissions(roleId, ["event:read"])) return null;
|
||||||
|
const effective = effectiveModulesFor(db);
|
||||||
|
const out: LedgerEventType[] = [];
|
||||||
|
for (const m of MODULES) {
|
||||||
|
if (!m.feedPermission || !effective.includes(m.id)) continue;
|
||||||
|
if (roleHasPermissions(roleId, [m.feedPermission])) out.push(...m.ledgerEventTypes);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQL form of the shared `tillOfEvent` rule: the payload's `till`, else the till of
|
||||||
|
* the module owning the event type, else the booth. Computed in the query so the
|
||||||
|
* page limit applies AFTER the till filter (a shift's window can hold thousands of
|
||||||
|
* device events). */
|
||||||
|
const tillExpr = (() => {
|
||||||
|
const cases = MODULES.filter((m) => m.till && m.till !== BOOTH_TILL && m.ledgerEventTypes.length > 0).map(
|
||||||
|
(m) => sql`when ${ledgerEvents.type} in (${sql.join(m.ledgerEventTypes.map((t) => sql`${t}`), sql`, `)}) then ${m.till}`,
|
||||||
|
);
|
||||||
|
return sql`coalesce(json_extract(${ledgerEvents.payload}, '$.till'), case ${sql.join(cases, sql` `)} else ${BOOTH_TILL} end)`;
|
||||||
|
})();
|
||||||
|
|
||||||
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
|
||||||
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
// Optional `since` (ISO) scopes to events at/after that instant — the booth passes
|
||||||
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
// the current shift's start so the live feed shows ONLY this shift's activity. An
|
||||||
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
// optional `until` (ISO) closes the upper bound — the shift-history screen passes a
|
||||||
// selected shift's [start, end] to show just that shift's signed activity log.
|
// selected shift's [start, end] to show just that shift's signed activity log.
|
||||||
// (logs are per-shift, not all history). See wiki/concepts/shift.md.
|
// (logs are per-shift, not all history). An optional `till` keeps only that till's
|
||||||
app.get<{ Querystring: { limit?: string; since?: string; until?: string } }>(
|
// activity (tillOfEvent) — a booth shift's log no longer shows the wash desk's, and
|
||||||
|
// vice versa. See wiki/concepts/shift.md §Tills.
|
||||||
|
app.get<{ Querystring: { limit?: string; since?: string; until?: string; till?: string } }>(
|
||||||
"/api/events",
|
"/api/events",
|
||||||
{ preHandler: guard },
|
{ preHandler: requireAuth },
|
||||||
async (req) => {
|
async (req, reply) => {
|
||||||
|
const types = readableTypes(req.user?.roleId ?? "");
|
||||||
|
if (types && types.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
||||||
const since = (req.query.since ?? "").trim();
|
const since = (req.query.since ?? "").trim();
|
||||||
const until = (req.query.until ?? "").trim();
|
const until = (req.query.until ?? "").trim();
|
||||||
|
const till = (req.query.till ?? "").trim();
|
||||||
|
if (till && !isTillId(till)) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
const bounds = [
|
const bounds = [
|
||||||
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
since ? gte(ledgerEvents.occurredAt, since) : undefined,
|
||||||
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
until ? lte(ledgerEvents.occurredAt, until) : undefined,
|
||||||
|
till ? sql`${tillExpr} = ${till}` : undefined,
|
||||||
|
types ? inArray(ledgerEvents.type, types) : undefined,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const rows = db
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { createTestDb } from "@parking/db/testing";
|
||||||
|
import { type Db } from "@parking/db";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { jobsBehind } from "@parking/shared";
|
||||||
|
import { buildServer } from "../server.js";
|
||||||
|
import { login, seedUser } from "../test-helpers.js";
|
||||||
|
|
||||||
|
// Roles are data composed from the permission grid (venue-modules.md §Permissions
|
||||||
|
// matrix): every edit is SIGNED as a config_change, and a role remembers the manifest
|
||||||
|
// JOBS it was built from so a grown job can be surfaced and re-applied.
|
||||||
|
|
||||||
|
let db: Db;
|
||||||
|
let close: () => void;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
beforeEach(async () => {
|
||||||
|
delete process.env.MODULES_ENTITLED;
|
||||||
|
const t = createTestDb();
|
||||||
|
db = t.db;
|
||||||
|
close = t.close;
|
||||||
|
app = await buildServer({ db });
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
type Auth = { cookie: string; csrf: string };
|
||||||
|
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||||
|
async function admin(): Promise<Auth> {
|
||||||
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||||
|
return login(app, username, password);
|
||||||
|
}
|
||||||
|
async function roleChanges(a: Auth) {
|
||||||
|
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||||
|
return (r.json().events as { type: string; payload: Record<string, unknown> }[]).filter(
|
||||||
|
(e) => e.type === "config_change" && String(e.payload.setting).startsWith("role."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("role edits are signed and jobs are remembered", () => {
|
||||||
|
it("create / update / delete each sign one config_change with prev + value + operator; a no-op resave signs nothing", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||||
|
payload: { name: "Lavazh", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(201);
|
||||||
|
const role = created.json();
|
||||||
|
expect(role.jobs).toEqual(["wash-operator"]);
|
||||||
|
let evs = await roleChanges(a);
|
||||||
|
expect(evs).toHaveLength(1);
|
||||||
|
expect(evs[0]!.payload).toMatchObject({
|
||||||
|
setting: `role.${role.id}`, prev: null, operator: "boss",
|
||||||
|
value: { name: "Lavazh", jobs: ["wash-operator"] },
|
||||||
|
});
|
||||||
|
expect((evs[0]!.payload.value as { permissions: string[] }).permissions).toEqual(["carwash:cash", "carwash:create", "carwash:read", "carwash:update"]);
|
||||||
|
|
||||||
|
// Same content again → nothing new on the chain.
|
||||||
|
const same = await app.inject({
|
||||||
|
method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a),
|
||||||
|
payload: { permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"], jobs: ["wash-operator"] },
|
||||||
|
});
|
||||||
|
expect(same.statusCode).toBe(200);
|
||||||
|
expect(await roleChanges(a)).toHaveLength(1);
|
||||||
|
|
||||||
|
// A real change: prev is the old shape, value the new.
|
||||||
|
const renamed = await app.inject({ method: "PUT", url: `/api/roles/${role.id}`, headers: hdrs(a), payload: { name: "Lavazh NEW" } });
|
||||||
|
expect(renamed.statusCode).toBe(200);
|
||||||
|
evs = await roleChanges(a);
|
||||||
|
expect(evs).toHaveLength(2);
|
||||||
|
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh" }, value: { name: "Lavazh NEW" } });
|
||||||
|
|
||||||
|
const gone = await app.inject({ method: "DELETE", url: `/api/roles/${role.id}`, headers: hdrs(a) });
|
||||||
|
expect(gone.statusCode).toBe(200);
|
||||||
|
evs = await roleChanges(a);
|
||||||
|
expect(evs).toHaveLength(3);
|
||||||
|
expect(evs[0]!.payload).toMatchObject({ prev: { name: "Lavazh NEW" }, value: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown jobs are refused; a role built from a job that later grew reports what it is missing", async () => {
|
||||||
|
const a = await admin();
|
||||||
|
const bad = await app.inject({ method: "POST", url: "/api/roles", headers: hdrs(a), payload: { name: "X", permissions: [], jobs: ["bar-tender"] } });
|
||||||
|
expect(bad.statusCode).toBe(400);
|
||||||
|
// Compose "behind": the role follows wash-operator but holds only part of today's bundle
|
||||||
|
// — exactly what an older release's chip would have left once the job grew.
|
||||||
|
const r = (await app.inject({
|
||||||
|
method: "POST", url: "/api/roles", headers: hdrs(a),
|
||||||
|
payload: { name: "Old wash", permissions: ["carwash:read", "carwash:create"], jobs: ["wash-operator"] },
|
||||||
|
})).json();
|
||||||
|
const view = (await app.inject({ method: "GET", url: "/api/roles", headers: { cookie: a.cookie } })).json().roles.find((x: { id: string }) => x.id === r.id);
|
||||||
|
const has = new Set<string>(view.permissions);
|
||||||
|
expect(jobsBehind(view.jobs, (p) => has.has(p))).toEqual([{ job: "wash-operator", missing: ["carwash:update", "carwash:cash"] }]);
|
||||||
|
// Re-apply = the union; then nothing is behind.
|
||||||
|
const fixed = (await app.inject({
|
||||||
|
method: "PUT", url: `/api/roles/${r.id}`, headers: hdrs(a),
|
||||||
|
payload: { permissions: [...has, "carwash:update", "carwash:cash"] },
|
||||||
|
})).json();
|
||||||
|
const has2 = new Set<string>(fixed.permissions);
|
||||||
|
expect(jobsBehind(fixed.jobs, (p) => has2.has(p))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, isNull, rolePermissions, roles, users, type Db } from "@parking/db";
|
import { and, eq, isNull, roleJobs, rolePermissions, roles, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID, PERMISSIONS, type Permission } from "@parking/shared";
|
import { ADMIN_ROLE_ID, PERMISSIONS, jobById, type Permission } from "@parking/shared";
|
||||||
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||||
|
import type { EventLog } from "../event-log.js";
|
||||||
import { softDelete } from "../recycle-bin.js";
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
// Role management (admin). Roles are DATA: an admin composes a role from the
|
// Role management (admin). Roles are DATA: an admin composes a role from the
|
||||||
@@ -18,14 +19,30 @@ import { softDelete } from "../recycle-bin.js";
|
|||||||
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
// that grants admin-equivalent powers, and escalate. So a non-admin caller may
|
||||||
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
// only put permissions they ALREADY hold onto a role. An admin (full set) is
|
||||||
// unrestricted, which is the intended behaviour.
|
// unrestricted, which is the intended behaviour.
|
||||||
|
//
|
||||||
|
// EVERY role edit is SIGNED on the ledger as a `config_change` (setting `role.<id>`,
|
||||||
|
// value/prev = the role's name + permissions + jobs, operator = who) — a role edit is a
|
||||||
|
// privilege change, and under this threat model the only setting an admin could alter
|
||||||
|
// without a trace. A role also REMEMBERS the manifest JOBS it was composed from
|
||||||
|
// (role_jobs) so a later release that grows a job's bundle can be surfaced and
|
||||||
|
// re-applied — the grid is never expanded silently (venue-modules.md §Permissions matrix).
|
||||||
|
|
||||||
interface RoleBody {
|
interface RoleBody {
|
||||||
name: string;
|
name: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
jobs?: string[];
|
||||||
}
|
}
|
||||||
interface UpdateBody {
|
interface UpdateBody {
|
||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
|
jobs?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a signed role change records (before/after). */
|
||||||
|
interface RoleShape {
|
||||||
|
name: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
jobs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID = new Set<string>(PERMISSIONS);
|
const VALID = new Set<string>(PERMISSIONS);
|
||||||
@@ -41,7 +58,19 @@ function cleanPermissions(input: unknown): { ok: true; perms: Permission[] } | {
|
|||||||
return { ok: true, perms: [...out] };
|
return { ok: true, perms: [...out] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
/** Validate + dedupe a requested job list against the registry's job presets. */
|
||||||
|
function cleanJobs(input: unknown): { ok: true; jobs: string[] } | { ok: false; bad: string } {
|
||||||
|
if (input == null) return { ok: true, jobs: [] };
|
||||||
|
if (!Array.isArray(input)) return { ok: false, bad: "jobs must be an array" };
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const j of input) {
|
||||||
|
if (typeof j !== "string" || !jobById(j)) return { ok: false, bad: `unknown job: ${String(j)}` };
|
||||||
|
out.add(j);
|
||||||
|
}
|
||||||
|
return { ok: true, jobs: [...out] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function roleRoutes(app: FastifyInstance, db: Db, eventLog?: EventLog): Promise<void> {
|
||||||
const readGuard = requirePermission("role:read");
|
const readGuard = requirePermission("role:read");
|
||||||
const createGuard = requirePermission("role:create");
|
const createGuard = requirePermission("role:create");
|
||||||
const updateGuard = requirePermission("role:update");
|
const updateGuard = requirePermission("role:update");
|
||||||
@@ -64,10 +93,39 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
name: role.name,
|
name: role.name,
|
||||||
builtin: role.builtin === 1,
|
builtin: role.builtin === 1,
|
||||||
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
|
permissions: role.id === ADMIN_ROLE_ID ? [...PERMISSIONS] : perms,
|
||||||
|
jobs: jobsOf(roleId),
|
||||||
userCount,
|
userCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jobsOf(roleId: string): string[] {
|
||||||
|
return db.select({ jobId: roleJobs.jobId }).from(roleJobs).where(eq(roleJobs.roleId, roleId)).all().map((r) => r.jobId).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The role as the ledger records it (sorted so two identical shapes compare equal). */
|
||||||
|
function shapeOf(roleId: string): RoleShape | null {
|
||||||
|
const v = roleView(roleId);
|
||||||
|
if (!v) return null;
|
||||||
|
return { name: v.name, permissions: [...v.permissions].sort() as Permission[], jobs: v.jobs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a role's remembered jobs. */
|
||||||
|
function setJobs(roleId: string, jobs: string[]): void {
|
||||||
|
db.delete(roleJobs).where(eq(roleJobs.roleId, roleId)).run();
|
||||||
|
for (const jobId of jobs) db.insert(roleJobs).values({ roleId, jobId }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign a role change. `prev` null = created; `value` null = deleted. Skipped when
|
||||||
|
* nothing changed (a no-op resave leaves no trace, like the site-config flips). */
|
||||||
|
async function signRoleChange(req: { user?: { username?: string } }, roleId: string, prev: RoleShape | null, value: RoleShape | null): Promise<void> {
|
||||||
|
if (JSON.stringify(prev) === JSON.stringify(value)) return;
|
||||||
|
await eventLog?.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
payload: { setting: `role.${roleId}`, value, prev, operator: req.user?.username ?? "unknown" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace a role's permission rows with `perms` (in a single pass). */
|
/** Replace a role's permission rows with `perms` (in a single pass). */
|
||||||
function setPermissions(roleId: string, perms: Permission[]): void {
|
function setPermissions(roleId: string, perms: Permission[]): void {
|
||||||
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId)).run();
|
||||||
@@ -104,13 +162,17 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
const cleaned = cleanPermissions(req.body?.permissions ?? []);
|
||||||
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
if (!cleaned.ok) return reply.code(400).send({ error: cleaned.bad });
|
||||||
|
const jobs = cleanJobs(req.body?.jobs);
|
||||||
|
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
|
||||||
const over = escalates(req.user.roleId, cleaned.perms);
|
const over = escalates(req.user.roleId, cleaned.perms);
|
||||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
||||||
setPermissions(id, cleaned.perms);
|
setPermissions(id, cleaned.perms);
|
||||||
|
setJobs(id, jobs.jobs);
|
||||||
bumpPermsCache();
|
bumpPermsCache();
|
||||||
|
await signRoleChange(req, id, null, shapeOf(id));
|
||||||
return reply.code(201).send(roleView(id));
|
return reply.code(201).send(roleView(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,6 +187,7 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
if (role.builtin === 1) {
|
if (role.builtin === 1) {
|
||||||
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
|
return reply.code(409).send({ error: "the built-in admin role cannot be edited" });
|
||||||
}
|
}
|
||||||
|
const prev = shapeOf(id);
|
||||||
|
|
||||||
if (req.body?.name != null) {
|
if (req.body?.name != null) {
|
||||||
const name = req.body.name.trim();
|
const name = req.body.name.trim();
|
||||||
@@ -140,7 +203,13 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
if (over) return reply.code(403).send({ error: `cannot grant a permission you do not hold: ${over}` });
|
||||||
setPermissions(id, cleaned.perms);
|
setPermissions(id, cleaned.perms);
|
||||||
}
|
}
|
||||||
|
if (req.body?.jobs != null) {
|
||||||
|
const jobs = cleanJobs(req.body.jobs);
|
||||||
|
if (!jobs.ok) return reply.code(400).send({ error: jobs.bad });
|
||||||
|
setJobs(id, jobs.jobs);
|
||||||
|
}
|
||||||
bumpPermsCache();
|
bumpPermsCache();
|
||||||
|
await signRoleChange(req, id, prev, shapeOf(id));
|
||||||
return roleView(id);
|
return roleView(id);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -163,8 +232,10 @@ export async function roleRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
if (holders > 0) {
|
if (holders > 0) {
|
||||||
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
return reply.code(409).send({ error: `cannot delete a role still assigned to ${holders} user(s)` });
|
||||||
}
|
}
|
||||||
|
const prev = shapeOf(id);
|
||||||
softDelete(db, "role", id, req.user.sub);
|
softDelete(db, "role", id, req.user.sub);
|
||||||
bumpPermsCache();
|
bumpPermsCache();
|
||||||
|
await signRoleChange(req, id, prev, null);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => {
|
|||||||
const { cookie } = await login(app, viewer.username, viewer.password);
|
const { cookie } = await login(app, viewer.username, viewer.password);
|
||||||
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
|
||||||
expect(ok.statusCode).toBe(200);
|
expect(ok.statusCode).toBe(200);
|
||||||
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
|
expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
|
||||||
|
|
||||||
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
|
||||||
const other = await login(app, outsider.username, outsider.password);
|
const other = await login(app, outsider.username, outsider.password);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import type { Db } from "@parking/db";
|
||||||
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
|
import { tillGuards, type TillId } from "@parking/shared";
|
||||||
|
import { requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
|
import { parseTill, requireTill, tillsReadableBy } from "../modules.js";
|
||||||
|
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, type ShiftSummary } from "../shift-service.js";
|
||||||
|
|
||||||
interface ShiftsQuery {
|
interface ShiftsQuery {
|
||||||
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
/** Filter to one operator (admin-only; non-admins are forced to themselves). */
|
||||||
@@ -8,85 +11,124 @@ interface ShiftsQuery {
|
|||||||
/** ISO window over shift START time. */
|
/** ISO window over shift START time. */
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Filter to one till; absent = every till the role may read. */
|
||||||
|
till?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
|
||||||
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
|
||||||
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
|
||||||
|
//
|
||||||
|
// TILLS + PERMISSIONS: every endpoint addresses a `till` (query on GET, body on POST;
|
||||||
|
// default booth) and its guard is resolved FROM THE TILL (requireTill): the booth's shift
|
||||||
|
// is `shift:read` / `shift:create`, the wash's is `carwash:read` / `carwash:cash` — each
|
||||||
|
// desk's money is guarded by that desk's own permissions, so a wash role holds no
|
||||||
|
// `shift:*` at all and cannot touch the booth. See venue-modules.md §"Permissions matrix".
|
||||||
|
|
||||||
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
|
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||||
// Reading the shift state vs. opening/closing one's own shift.
|
const statusOf = (till: TillId, me: string, roleId: string) => {
|
||||||
const readGuard = requirePermission("shift:read");
|
const open = shift.currentOpenShift(till);
|
||||||
const guard = requirePermission("shift:create");
|
|
||||||
|
|
||||||
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
|
|
||||||
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
|
|
||||||
// someone else's shift → disabled. Also returns the live drawer balance.
|
|
||||||
// - open: the open shift { startedAt, operator } or null (site-wide)
|
|
||||||
// - isMine: true iff the open shift belongs to the requesting operator
|
|
||||||
// - operator: the requesting user (for the UI's own identity)
|
|
||||||
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
|
|
||||||
const me = req.user.username;
|
|
||||||
const open = shift.currentOpenShift();
|
|
||||||
const heldBy = open?.identity ?? null;
|
const heldBy = open?.identity ?? null;
|
||||||
const drawer = shift.drawerBalance();
|
const drawer = shift.drawerBalance(till);
|
||||||
return {
|
return {
|
||||||
operator: me,
|
till,
|
||||||
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
|
||||||
isMine: open != null && heldBy === me,
|
isMine: open != null && heldBy === me,
|
||||||
|
/** May this role open/close this till's shift? (The UI offers the button only then.) */
|
||||||
|
canWork: roleHasPermissions(roleId, [tillGuards(till).shift]),
|
||||||
drawerMinor: drawer.balanceMinor,
|
drawerMinor: drawer.balanceMinor,
|
||||||
currency: drawer.currency,
|
currency: drawer.currency,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The shift state of ONE till (at most one shift open per till). The UI uses this
|
||||||
|
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
|
||||||
|
// someone else's shift → disabled. Also returns the live drawer balance.
|
||||||
|
// - till: which till this describes
|
||||||
|
// - open: the open shift { startedAt, operator } or null
|
||||||
|
// - isMine: true iff the open shift belongs to the requesting operator
|
||||||
|
// - canWork: may this role open/close it
|
||||||
|
// - operator: the requesting user (for the UI's own identity)
|
||||||
|
// - tills: every till THIS ROLE may read — what the UI offers controls for
|
||||||
|
app.get("/api/shift/current", { preHandler: requireTill(db, "read", "query") }, async (req) => ({
|
||||||
|
operator: req.user.username,
|
||||||
|
tills: tillsReadableBy(db, req.user.roleId),
|
||||||
|
...statusOf(req.till!, req.user.username, req.user.roleId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The state of every till this role may read, in one read — the shift hub lists
|
||||||
|
// each open shift and offers "start" for the idle ones it may work.
|
||||||
|
app.get("/api/shift/tills", { preHandler: requireAuth }, async (req) => {
|
||||||
|
const me = req.user.username;
|
||||||
|
return { operator: me, tills: tillsReadableBy(db, req.user.roleId).map((t) => statusOf(t, me, req.user.roleId)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
|
||||||
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
|
||||||
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
// as of now. Appends nothing — it's not an accountability mark, just a projection
|
||||||
// (the Z-report at close is the signed record). 204 when no shift is open.
|
// (the Z-report at close is the signed record). 204 when no shift is open.
|
||||||
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
|
app.get("/api/shift/report", { preHandler: requireTill(db, "read", "query") }, async (req, reply) => {
|
||||||
const report = shift.currentReport();
|
const report = shift.currentReport(req.till!);
|
||||||
if (!report) return reply.code(204).send();
|
if (!report) return reply.code(204).send();
|
||||||
return report;
|
return report;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Completed shift history. SCOPED by permission:
|
// Completed shift history. SCOPED by permission:
|
||||||
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
|
// - a till's `read` guard (operators) → own shifts only, on the tills they may read;
|
||||||
|
// operator/from/to params ignored.
|
||||||
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
|
||||||
// `operator` and a `from`/`to` time window over each shift's START.
|
// `operator` and a `from`/`to` time window over each shift's START.
|
||||||
// This keeps one operator from reading another's takings while letting admins
|
// This keeps one operator from reading another's takings while letting admins
|
||||||
// reconcile across the site. The data is the signed shift_z_report chain.
|
// reconcile across the site. The data is the signed shift_z_report chain. Both
|
||||||
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
|
// scopes may filter by `till` (must be one the role may read).
|
||||||
|
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
|
||||||
|
const readable = tillsReadableBy(db, req.user.roleId);
|
||||||
|
if (!canSeeAll && readable.length === 0) return reply.code(403).send({ error: "forbidden" });
|
||||||
const q = req.query ?? {};
|
const q = req.query ?? {};
|
||||||
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
// Non-admins are hard-scoped to themselves regardless of any operator param.
|
||||||
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
|
||||||
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
|
||||||
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
|
||||||
const shifts = shift.listShifts({ operator, from, to });
|
let tills: TillId[] = canSeeAll ? [] : readable; // [] = no till filter (admin)
|
||||||
|
if (q.till?.trim()) {
|
||||||
|
const parsed = parseTill(db, q.till.trim());
|
||||||
|
if (!parsed) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
|
||||||
|
if (!canSeeAll && !readable.includes(parsed)) return badTill(reply, parsed);
|
||||||
|
tills = [parsed];
|
||||||
|
}
|
||||||
|
const shifts: ShiftSummary[] =
|
||||||
|
tills.length === 0
|
||||||
|
? shift.listShifts({ operator, from, to })
|
||||||
|
: tills.flatMap((till) => shift.listShifts({ operator, from, to, till })).sort((a, b) => b.index - a.index);
|
||||||
// Admins also get the distinct operator list (unfiltered) for the filter
|
// Admins also get the distinct operator list (unfiltered) for the filter
|
||||||
// dropdown — operators don't see other names, so it's scope-gated.
|
// dropdown — operators don't see other names, so it's scope-gated.
|
||||||
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
|
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: tillsReadableBy(db, req.user.roleId) };
|
||||||
return { shifts, scope: "self" };
|
return { shifts, scope: "self", tills: readable };
|
||||||
});
|
});
|
||||||
|
|
||||||
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
|
||||||
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/open", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.open(req.user.username);
|
return await shift.open(req.user.username, req.till!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
|
app.post("/api/shift/close", { preHandler: requireTill(db, "shift", "body") }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.close(req.user.username);
|
return await shift.close(req.user.username, req.till!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
|
||||||
return reply.code(500).send({ error: (err as Error).message });
|
return reply.code(500).send({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badTill(reply: FastifyReply, till: TillId): FastifyReply {
|
||||||
|
return reply.code(403).send({ error: `your role cannot see the ${till} till`, code: "till_forbidden", till });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, siteConfig, type Db } from "@parking/db";
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
|
import { MODULES, effectiveModules, isModuleId, resolveModuleActivation, type ModuleId } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
|
import { activatedModulesOf, entitledModules } from "../modules.js";
|
||||||
import { getOccupancy } from "../occupancy.js";
|
import { getOccupancy } from "../occupancy.js";
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
// Site config (capacity) + live occupancy. Occupancy is a fold over the signed
|
||||||
@@ -36,6 +38,10 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
|
|||||||
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
/** Master switch for the ANPR subscriber-entry bridge (auto-open on a subscriber's
|
||||||
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
* plate read). OFF → subscribers fall back to card/QR; advisory ANPR still records. */
|
||||||
anprEntryEnabled?: boolean;
|
anprEntryEnabled?: boolean;
|
||||||
|
/** Venue modules to ACTIVATE (full desired set). Validated against the entitlement
|
||||||
|
* and the registry's dependency rules; required modules are always included. Each
|
||||||
|
* module that actually flips signs a config_change. See wiki/decisions/venue-modules.md. */
|
||||||
|
modules?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
|
||||||
@@ -48,6 +54,13 @@ type SiteConfig = {
|
|||||||
anprEntryEnabled: boolean;
|
anprEntryEnabled: boolean;
|
||||||
bypassPresenceRadar: boolean;
|
bypassPresenceRadar: boolean;
|
||||||
bypassPresenceCamera: boolean;
|
bypassPresenceCamera: boolean;
|
||||||
|
/** Effective venue modules = entitled ∩ activated (what the server enforces). */
|
||||||
|
modules: ModuleId[];
|
||||||
|
/** What this deployment is entitled to (MODULES_ENTITLED env) — the Setup → Site
|
||||||
|
* panel offers exactly these to toggle. */
|
||||||
|
modulesEntitled: ModuleId[];
|
||||||
|
/** What the site admin has activated (null in storage = everything entitled). */
|
||||||
|
modulesActivated: ModuleId[];
|
||||||
} & Record<TextField, string | null>;
|
} & Record<TextField, string | null>;
|
||||||
|
|
||||||
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
|
||||||
@@ -59,11 +72,22 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
|
|||||||
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
anprEntryEnabled: row?.anprEntryEnabled ?? true,
|
||||||
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
bypassPresenceRadar: row?.bypassPresenceRadar ?? false,
|
||||||
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
bypassPresenceCamera: row?.bypassPresenceCamera ?? false,
|
||||||
|
...moduleView(row),
|
||||||
} as SiteConfig;
|
} as SiteConfig;
|
||||||
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moduleView(row: typeof siteConfig.$inferSelect | undefined) {
|
||||||
|
const entitled = entitledModules();
|
||||||
|
const activated = activatedModulesOf(row) ?? entitled;
|
||||||
|
return {
|
||||||
|
modules: effectiveModules(entitled, activated),
|
||||||
|
modulesEntitled: entitled,
|
||||||
|
modulesActivated: activated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Trim a text field; empty string becomes null so blank input clears it. */
|
/** Trim a text field; empty string becomes null so blank input clears it. */
|
||||||
function normText(v: unknown): string | null {
|
function normText(v: unknown): string | null {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
@@ -136,6 +160,40 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
|
|
||||||
|
// Venue-module activation. The body carries the full DESIRED set; the shared rules
|
||||||
|
// (required always on, must be entitled, dependencies effective) decide, and every
|
||||||
|
// module whose effective state actually flips is signed as a config_change — the
|
||||||
|
// same attribution pattern as the presence-bypass endpoint below. Disabling never
|
||||||
|
// deletes anything: tables/history/grants stay, routes 403, UI hides.
|
||||||
|
if ("modules" in body) {
|
||||||
|
const requested = body.modules;
|
||||||
|
if (!Array.isArray(requested) || !requested.every(isModuleId)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: `modules must be an array of module ids (${MODULES.map((m) => m.id).join(", ")})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const entitled = entitledModules();
|
||||||
|
const result = resolveModuleActivation(entitled, requested);
|
||||||
|
if (!result.ok) return reply.code(400).send({ error: result.error });
|
||||||
|
const prevEffective = new Set(effectiveModules(entitled, activatedModulesOf(existing) ?? entitled));
|
||||||
|
const nextEffective = new Set(effectiveModules(entitled, result.modules));
|
||||||
|
const operator = req.user?.username ?? "unknown";
|
||||||
|
for (const m of MODULES) {
|
||||||
|
const was = prevEffective.has(m.id);
|
||||||
|
const now = nextEffective.has(m.id);
|
||||||
|
if (was !== now) {
|
||||||
|
await eventLog?.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `module:${m.id}`,
|
||||||
|
payload: { setting: `modules.${m.id}`, value: now, prev: was, operator },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.modulesJson = JSON.stringify(result.modules);
|
||||||
|
}
|
||||||
|
|
||||||
const updatedAt = new Date().toISOString();
|
const updatedAt = new Date().toISOString();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
import { NoPrinterAvailableError } from "@parking/devices";
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
|
||||||
import { requirePermission, roleHasPermissions } from "../auth.js";
|
import { requirePermission, roleHasPermissions } from "../auth.js";
|
||||||
import { softDelete } from "../recycle-bin.js";
|
import { softDelete } from "../recycle-bin.js";
|
||||||
import { invalidateHolder } from "../event-enrich.js";
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
|
|||||||
amountMinor,
|
amountMinor,
|
||||||
currency,
|
currency,
|
||||||
tender,
|
tender,
|
||||||
|
till: BOOTH_TILL,
|
||||||
operator,
|
operator,
|
||||||
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
// Flags this `payment` as a subscription SALE (not a parking payment) so the
|
||||||
// live feed / activity log can label it distinctly. plan + periods for audit
|
// live feed / activity log can label it distinctly. plan + periods for audit
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import bcrypt from "bcrypt";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
import { and, eq, isNull, roles, users, type Db } from "@parking/db";
|
||||||
import { ADMIN_ROLE_ID } from "@parking/shared";
|
import { ADMIN_ROLE_ID } from "@parking/shared";
|
||||||
import { permissionsFor, requirePermission } from "../auth.js";
|
import { bumpPermsCache, permissionsFor, requirePermission } from "../auth.js";
|
||||||
import { softDelete } from "../recycle-bin.js";
|
import { softDelete } from "../recycle-bin.js";
|
||||||
|
|
||||||
// User management (admin). Users are created/edited at runtime here — the
|
// User management (admin). Users are created/edited at runtime here — the
|
||||||
@@ -202,6 +202,8 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(400).send({ error: "nothing to update" });
|
return reply.code(400).send({ error: "nothing to update" });
|
||||||
}
|
}
|
||||||
db.update(users).set(next).where(eq(users.id, id)).run();
|
db.update(users).set(next).where(eq(users.id, id)).run();
|
||||||
|
// A role reassignment takes effect on the user's NEXT request (auth.ts refreshRole).
|
||||||
|
if (next.roleId) bumpPermsCache();
|
||||||
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
return publicUser(db.select().from(users).where(eq(users.id, id)).get()!);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -251,6 +253,7 @@ export async function userRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
return reply.code(409).send({ error: "cannot delete the last admin" });
|
return reply.code(409).send({ error: "cannot delete the last admin" });
|
||||||
}
|
}
|
||||||
softDelete(db, "user", id, req.user.sub);
|
softDelete(db, "user", id, req.user.sub);
|
||||||
|
bumpPermsCache(); // their live session ends on its next request
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import {
|
|||||||
validationPrograms,
|
validationPrograms,
|
||||||
type Db,
|
type Db,
|
||||||
} from "@parking/db";
|
} from "@parking/db";
|
||||||
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
|
import { requireModule } from "../modules.js";
|
||||||
import type { EventLog } from "../event-log.js";
|
import type { EventLog } from "../event-log.js";
|
||||||
import { liveValidations, sessionValidations } from "../validations.js";
|
import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
|
||||||
|
|
||||||
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
|
||||||
// customer's ticket on their own device and apply their program — all money and paper
|
// customer's ticket on their own device and apply their program — all money and paper
|
||||||
@@ -63,21 +64,33 @@ function validateProgram(b: ProgramBody): string | null {
|
|||||||
if (!b.name || !String(b.name).trim()) return "name is required";
|
if (!b.name || !String(b.name).trim()) return "name is required";
|
||||||
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
|
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
|
||||||
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
|
||||||
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
|
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
|
||||||
|
// minute more"; every other minutes use is a positive credit.
|
||||||
|
const minutesOk = b.mode === "doneTolerance"
|
||||||
|
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
|
||||||
|
: intOrNull(b.minutes);
|
||||||
|
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
|
||||||
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
|
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
|
||||||
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
|
||||||
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
|
||||||
return "percent must be 1..100";
|
return "percent must be 1..100";
|
||||||
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
|
||||||
|
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
|
||||||
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
|
||||||
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise<void> {
|
||||||
|
// The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
|
||||||
|
// serves every module that grants a parking discount (Car Wash's "carwash" program
|
||||||
|
// rides it), so they are never behind the validation module gate — plain site:read /
|
||||||
|
// site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
|
||||||
|
// are the validation module itself: module gate FIRST (403 module_disabled when the
|
||||||
|
// site has validation off — see ../modules.ts), then the permission.
|
||||||
const siteRead = requirePermission("site:read");
|
const siteRead = requirePermission("site:read");
|
||||||
const siteWrite = requirePermission("site:update");
|
const siteWrite = requirePermission("site:update");
|
||||||
const applyGuard = requirePermission("validation:create");
|
const applyGuard = [requireModule(db, "validation"), requirePermission("validation:create")];
|
||||||
|
|
||||||
const liveProgram = (id: string) =>
|
const liveProgram = (id: string) =>
|
||||||
db
|
db
|
||||||
@@ -243,88 +256,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E
|
|||||||
if (!boundUserIds(programId).includes(req.user.sub)) {
|
if (!boundUserIds(programId).includes(req.user.sub)) {
|
||||||
return reply.code(403).send({ error: "you are not bound to this program" });
|
return reply.code(403).send({ error: "you are not bound to this program" });
|
||||||
}
|
}
|
||||||
|
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
|
||||||
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
|
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
|
||||||
const rows = db
|
|
||||||
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
|
|
||||||
.from(ledgerEvents)
|
|
||||||
.where(eq(ledgerEvents.identity, identity))
|
|
||||||
.orderBy(ledgerEvents.index)
|
|
||||||
.all();
|
|
||||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
|
||||||
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
|
|
||||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
|
||||||
if (entryPl.permit === true || entryPl.permitId != null) {
|
|
||||||
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
|
|
||||||
}
|
|
||||||
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
|
||||||
return reply.code(409).send({ error: "session is closed" });
|
|
||||||
}
|
|
||||||
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
|
||||||
return reply.code(409).send({ error: "this program is already applied to the ticket" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
// The decision chain + the signed append live in ../validations.ts (applyValidation)
|
||||||
// appliance runs in site time).
|
// — shared with the Car Wash module, which applies its own sponsorship program with
|
||||||
if (program.maxPerDay != null) {
|
// no user binding. Only the binding check above is merchant-specific.
|
||||||
const midnight = new Date();
|
const result = await applyValidation(db, eventLog, {
|
||||||
midnight.setHours(0, 0, 0, 0);
|
|
||||||
const todays = db
|
|
||||||
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
|
||||||
.from(ledgerEvents)
|
|
||||||
.where(eq(ledgerEvents.type, "validation"))
|
|
||||||
.all()
|
|
||||||
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
|
||||||
const voidedIds = new Set(
|
|
||||||
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
|
||||||
);
|
|
||||||
const count = todays.filter((r) => {
|
|
||||||
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
|
||||||
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
|
||||||
}).length;
|
|
||||||
if (count >= program.maxPerDay) {
|
|
||||||
return reply.code(409).send({ error: "daily cap reached for this program" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the values off the program row (frozen into the signed event).
|
|
||||||
let amountMinor: number | undefined;
|
|
||||||
if (program.mode === "fixed") {
|
|
||||||
const a = req.body?.amountMinor;
|
|
||||||
if (a == null || !Number.isInteger(a) || a <= 0) {
|
|
||||||
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
|
|
||||||
}
|
|
||||||
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
|
||||||
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
|
|
||||||
}
|
|
||||||
amountMinor = a;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ev = await eventLog.append({
|
|
||||||
type: "validation",
|
|
||||||
source: "manual",
|
|
||||||
identity,
|
|
||||||
payload: {
|
|
||||||
sessionRef: identity,
|
|
||||||
programId,
|
|
||||||
programLabel: program.name,
|
|
||||||
mode: program.mode,
|
|
||||||
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
|
|
||||||
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
|
|
||||||
...(amountMinor != null ? { amountMinor } : {}),
|
|
||||||
operator: req.user.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return reply.code(201).send({
|
|
||||||
ok: true,
|
|
||||||
eventId: ev.id,
|
|
||||||
programId,
|
programId,
|
||||||
label: program.name,
|
identity,
|
||||||
mode: program.mode,
|
actor: req.user.username,
|
||||||
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
amountMinor: req.body?.amountMinor,
|
||||||
percent: program.mode === "percent" ? program.percent : undefined,
|
|
||||||
amountMinor,
|
|
||||||
});
|
});
|
||||||
|
if (!result.ok) return reply.code(result.status).send({ error: result.error });
|
||||||
|
return reply.code(201).send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Db } from "@parking/db";
|
import type { Db } from "@parking/db";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import { feedPermissionFor, watchPermissions, type LedgerEvent, type Permission } from "@parking/shared";
|
||||||
import { requireAuth, roleHasPermissions } from "../auth.js";
|
import { currentRoleId, requireAuth, roleHasPermissions } from "../auth.js";
|
||||||
|
import { effectiveModulesFor } from "../modules.js";
|
||||||
import {
|
import {
|
||||||
deviceEvents,
|
deviceEvents,
|
||||||
type LaneStatusEvent,
|
type LaneStatusEvent,
|
||||||
@@ -45,9 +46,14 @@ import { getOccupancy } from "../occupancy.js";
|
|||||||
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
// headers on a WebSocket, so this path is unreachable from a browser and adds
|
||||||
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
// no CSWSH surface; the Origin allowlist still applies to both paths.
|
||||||
|
|
||||||
/** Permission required to watch the live feed (a read-only stream of ledger +
|
// WHO may watch, and WHAT they see (venue-modules.md §"Permissions matrix", move 3):
|
||||||
* device status). Any role granted `report:read` may watch. */
|
// a role connects if it holds ANY watch permission — the core feed/occupancy/device
|
||||||
const WATCH_PERMISSION = "report:read" as const;
|
// ones or an effective module's own (carwash:read) — and every pushed message is then
|
||||||
|
// FILTERED per role: a ledger event needs feedPermissionFor(type) (the owning module's,
|
||||||
|
// else event:read); occupancy + the plate backfill need session:read; device / printer /
|
||||||
|
// lane / radar need device:read. `report:read` is the REPORTS screen, not the socket: the
|
||||||
|
// wash desk gets a live queue without the booth's ledger, the booth a feed without reports.
|
||||||
|
type Viewer = { has: (p: Permission) => boolean };
|
||||||
|
|
||||||
/** Handshake header carrying a desktop WS ticket (see file header). */
|
/** Handshake header carrying a desktop WS ticket (see file header). */
|
||||||
const WS_TICKET_HEADER = "x-ws-ticket";
|
const WS_TICKET_HEADER = "x-ws-ticket";
|
||||||
@@ -108,18 +114,25 @@ function isAllowedOrigin(origin: string | undefined, host: string | undefined):
|
|||||||
type OutMsg =
|
type OutMsg =
|
||||||
| {
|
| {
|
||||||
kind: "hello";
|
kind: "hello";
|
||||||
occupancy: ReturnType<typeof getOccupancy>;
|
occupancy: ReturnType<typeof getOccupancy> | null;
|
||||||
devices: unknown;
|
devices: unknown;
|
||||||
lanes: LaneStatusEvent;
|
lanes: LaneStatusEvent | null;
|
||||||
radar: LanePresenceEvent;
|
radar: LanePresenceEvent | null;
|
||||||
}
|
}
|
||||||
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> }
|
| { kind: "ledger"; event: unknown; occupancy: ReturnType<typeof getOccupancy> | null }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: unknown }
|
| { kind: "device-status"; event: unknown }
|
||||||
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
| { kind: "lane-status"; lanes: LaneStatusEvent }
|
||||||
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
| { kind: "lane-presence"; radar: LanePresenceEvent }
|
||||||
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
| { kind: "plate-recognized"; plate: PlateRecognizedEvent };
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
/** The role the WS preHandler authenticated (ticket or cookie path) — for the handler's filter. */
|
||||||
|
wsRoleId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function wsRoutes(
|
export async function wsRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -161,14 +174,18 @@ export async function wsRoutes(
|
|||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
roleId = req.user.roleId;
|
roleId = currentRoleId(req.user.sub) ?? "";
|
||||||
}
|
|
||||||
if (!roleHasPermissions(roleId, [WATCH_PERMISSION])) {
|
|
||||||
throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
|
||||||
}
|
}
|
||||||
|
const may = watchPermissions(effectiveModulesFor(db)).some((p) => roleHasPermissions(roleId, [p]));
|
||||||
|
if (!may) throw Object.assign(new Error("forbidden"), { statusCode: 403 });
|
||||||
|
req.wsRoleId = roleId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
(socket) => {
|
(socket, req) => {
|
||||||
|
const roleId = req.wsRoleId ?? req.user?.roleId ?? "";
|
||||||
|
const viewer: Viewer = { has: (p) => roleHasPermissions(roleId, [p]) };
|
||||||
|
const seesOccupancy = viewer.has("session:read");
|
||||||
|
const seesDevices = viewer.has("device:read");
|
||||||
const send = (msg: OutMsg) => {
|
const send = (msg: OutMsg) => {
|
||||||
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
// readyState 1 = OPEN; never throw out of an event-bus callback.
|
||||||
if (socket.readyState === 1) {
|
if (socket.readyState === 1) {
|
||||||
@@ -182,40 +199,43 @@ export async function wsRoutes(
|
|||||||
|
|
||||||
// Initial snapshot so the client renders immediately, before any event:
|
// Initial snapshot so the client renders immediately, before any event:
|
||||||
// occupancy AND the current device-status set (for the footer).
|
// occupancy AND the current device-status set (for the footer).
|
||||||
|
// Each part of the snapshot only for a role that may see it (null otherwise).
|
||||||
send({
|
send({
|
||||||
kind: "hello",
|
kind: "hello",
|
||||||
occupancy: getOccupancy(db),
|
occupancy: seesOccupancy ? getOccupancy(db) : null,
|
||||||
devices: deviceMonitor.snapshot(),
|
devices: seesDevices ? deviceMonitor.snapshot() : null,
|
||||||
lanes: laneStatus.snapshot(),
|
lanes: seesDevices ? laneStatus.snapshot() : null,
|
||||||
radar: lanePresence.snapshot(),
|
radar: seesDevices ? lanePresence.snapshot() : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
// Subscribe to the live buses. Each handler recomputes occupancy from the
|
||||||
// ledger (cheap fold) so the pushed count is always authoritative.
|
// ledger (cheap fold) so the pushed count is always authoritative.
|
||||||
const offLedger = deviceEvents.onLedger((event) => {
|
const offLedger = deviceEvents.onLedger((event) => {
|
||||||
|
// Per-role filter: the event type's feed permission (module's own, else event:read).
|
||||||
|
if (!viewer.has(feedPermissionFor((event as { type: LedgerEvent["type"] }).type))) return;
|
||||||
// Enrich with read-time display fields (subscriber name) before fan-out.
|
// Enrich with read-time display fields (subscriber name) before fan-out.
|
||||||
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
const enriched = enrichEvent(db, event as unknown as LedgerEvent);
|
||||||
send({ kind: "ledger", event: enriched, occupancy: getOccupancy(db) });
|
send({ kind: "ledger", event: enriched, occupancy: seesOccupancy ? getOccupancy(db) : null });
|
||||||
});
|
});
|
||||||
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
const offPrinter = deviceEvents.onPrinterStatus((event) => {
|
||||||
send({ kind: "printer-status", event });
|
if (seesDevices) send({ kind: "printer-status", event });
|
||||||
});
|
});
|
||||||
// Unified device status (all categories) for the booth footer — pushed on
|
// Unified device status (all categories) for the booth footer — pushed on
|
||||||
// change; the initial set rode the hello above.
|
// change; the initial set rode the hello above.
|
||||||
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
const offDevice = deviceEvents.onDeviceStatus((event) => {
|
||||||
send({ kind: "device-status", event });
|
if (seesDevices) send({ kind: "device-status", event });
|
||||||
});
|
});
|
||||||
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
// Lane busy/free (camera vehicle detection → booth barrier lights). Advisory.
|
||||||
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
const offLane = deviceEvents.onLaneStatus((lanes) => {
|
||||||
send({ kind: "lane-status", lanes });
|
if (seesDevices) send({ kind: "lane-status", lanes });
|
||||||
});
|
});
|
||||||
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
// Lane RADAR presence (presence-input edge → barrier-light blink). Advisory.
|
||||||
const offPresence = deviceEvents.onLanePresence((radar) => {
|
const offPresence = deviceEvents.onLanePresence((radar) => {
|
||||||
send({ kind: "lane-presence", radar });
|
if (seesDevices) send({ kind: "lane-presence", radar });
|
||||||
});
|
});
|
||||||
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
// A late async plate recognition → backfill the badge on the matching feed row. Advisory.
|
||||||
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
const offPlate = deviceEvents.onPlateRecognized((plate) => {
|
||||||
send({ kind: "plate-recognized", plate });
|
if (seesOccupancy) send({ kind: "plate-recognized", plate });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("close", () => {
|
socket.on("close", () => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { shiftRoutes } from "./routes/shift.js";
|
|||||||
import { drawerRoutes } from "./routes/drawer.js";
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
import { entryRoutes } from "./routes/entry.js";
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
import { validationRoutes } from "./routes/validations.js";
|
import { registerModules } from "./modules/index.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
@@ -117,7 +117,6 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
// RBAC administration: compose roles (role:*) + manage users (user:*). The
|
||||||
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
// built-in admin role is protected; the last admin can't be removed. See auth.ts.
|
||||||
await userRoutes(app, db);
|
await userRoutes(app, db);
|
||||||
await roleRoutes(app, db);
|
|
||||||
|
|
||||||
// Vision (ANPR) client — built early so the device monitor can include the vision
|
// Vision (ANPR) client — built early so the device monitor can include the vision
|
||||||
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
// service's health in the footer, AND so the setup wizard's "Test ANPR" can run a
|
||||||
@@ -144,6 +143,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
// no lane — a parking lot is one pool with a flexible set of entry/exit points.
|
||||||
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
// See wiki/concepts/first-run-setup.md, entry-exit-points.md.
|
||||||
await setupRoutes(app, db, visionClient, eventLog);
|
await setupRoutes(app, db, visionClient, eventLog);
|
||||||
|
await roleRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
@@ -286,9 +286,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await subscriptionPlanRoutes(app, db);
|
await subscriptionPlanRoutes(app, db);
|
||||||
|
|
||||||
// Shift open/close (shiftService constructed above).
|
// Shift open/close (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, db, shiftService);
|
||||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||||
await drawerRoutes(app, shiftService);
|
await drawerRoutes(app, db, shiftService);
|
||||||
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||||
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||||
|
|
||||||
@@ -296,10 +296,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
await siteRoutes(app, db, eventLog);
|
await siteRoutes(app, db, eventLog);
|
||||||
|
|
||||||
// Merchant validations (bar / lavazh): setup panel config + the merchant user's
|
// Venue modules (wiki/decisions/venue-modules.md): folder-based modules register
|
||||||
// scan-and-apply. The booth settlement folds the applied validations into its
|
// here by iterating the shared registry — today that is `validation` (merchant
|
||||||
// quote (pay-station.ts). See wiki/concepts/validation-discounts.md.
|
// validations for the Bar; the booth settlement folds applied validations into its
|
||||||
await validationRoutes(app, db, eventLog);
|
// quote, pay-station.ts). `parking` is in the registry too but its routes are still
|
||||||
|
// the flat list above; they move behind the seam subsystem by subsystem.
|
||||||
|
await registerModules(app, { db, eventLog, payStation, shiftService });
|
||||||
|
|
||||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
|
|||||||
@@ -242,3 +242,93 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
|||||||
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
expect(shift.listOperators()).toEqual(["alice", "bob"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tills: one shift per till, one drawer per till", () => {
|
||||||
|
/** A bay payment as the Car Wash module signs it (till = carwash). */
|
||||||
|
async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") {
|
||||||
|
await log.append({
|
||||||
|
type: "carwash_payment", source: "manual", identity: "T",
|
||||||
|
payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("the booth and the carwash till can both be open at once, by different operators", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" });
|
||||||
|
expect(shift.currentOpenShift()?.identity).toBe("alice");
|
||||||
|
expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda");
|
||||||
|
// Each till keeps its own single-open rule.
|
||||||
|
await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requireOpenShift is per till: a booth shift does not cover the bay", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError);
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
expect(shift.requireOpenShift("carwash").identity).toBe("wanda");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
await payment(10000); // booth (payment events carry till=booth or nothing)
|
||||||
|
await bayPayment(70000);
|
||||||
|
await bayPayment(20000, "card");
|
||||||
|
|
||||||
|
const booth = shift.currentReport()!;
|
||||||
|
expect(booth.till).toBe("booth");
|
||||||
|
expect(booth.cashTotalMinor).toBe(10000);
|
||||||
|
expect(booth.paymentCount).toBe(1);
|
||||||
|
expect(booth.expectedDrawerMinor).toBe(10000);
|
||||||
|
|
||||||
|
const wash = shift.currentReport("carwash")!;
|
||||||
|
expect(wash.till).toBe("carwash");
|
||||||
|
expect(wash.cashTotalMinor).toBe(70000);
|
||||||
|
expect(wash.cardTotalMinor).toBe(20000);
|
||||||
|
expect(wash.paymentCount).toBe(2);
|
||||||
|
expect(wash.expectedDrawerMinor).toBe(70000);
|
||||||
|
|
||||||
|
expect(shift.drawerBalance().balanceMinor).toBe(10000);
|
||||||
|
expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vouchers name their till; each till's expected drawer carries forward on its own", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" });
|
||||||
|
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" });
|
||||||
|
await bayPayment(70000);
|
||||||
|
expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]);
|
||||||
|
|
||||||
|
const washZ = await shift.close("wanda", "carwash");
|
||||||
|
expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 });
|
||||||
|
const boothZ = await shift.close("alice");
|
||||||
|
expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 });
|
||||||
|
|
||||||
|
// Next shift on each till inherits that till's drawer only.
|
||||||
|
expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000);
|
||||||
|
expect((await shift.open("bob")).openingFloatMinor).toBe(100000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close is per till: closing the booth never closes the wash desk", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("alice", "carwash");
|
||||||
|
await shift.close("alice");
|
||||||
|
expect(shift.currentOpenShift()).toBeNull();
|
||||||
|
expect(shift.currentOpenShift("carwash")?.identity).toBe("alice");
|
||||||
|
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("history lists both tills, filterable; pre-till reports read as booth", async () => {
|
||||||
|
await shift.open("alice");
|
||||||
|
await shift.open("wanda", "carwash");
|
||||||
|
await shift.close("wanda", "carwash");
|
||||||
|
await shift.close("alice");
|
||||||
|
const all = shift.listShifts();
|
||||||
|
expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]);
|
||||||
|
expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]);
|
||||||
|
expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]);
|
||||||
|
expect(shift.listOperators("carwash")).toEqual(["wanda"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+243
-125
@@ -1,6 +1,6 @@
|
|||||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
|
||||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
import { orderForRole, printerRoleOf, registry, formatStampSq as zStamp, type PrinterDevice, type PrinterInstance, type PrinterRole } from "@parking/devices";
|
||||||
import type { LedgerPayload } from "@parking/shared";
|
import { BOOTH_TILL, tillOf, type ChargeLine, type LedgerPayload, type ModuleId, type TillId } from "@parking/shared";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
|
|
||||||
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
|
|||||||
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
|
||||||
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
|
||||||
// `payment` events taken during the shift by tender and print a Z-report.
|
// `payment` events taken during the shift by tender and print a Z-report.
|
||||||
|
//
|
||||||
|
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
|
||||||
|
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
|
||||||
|
// its own operator, opening float, expected drawer and Z-report. Every money event
|
||||||
|
// names its till (`payload.till`; absent = booth, which is what every pre-till event
|
||||||
|
// is), and every fold in this file filters by it. Every public method takes the till,
|
||||||
|
// defaulting to the booth so the parking paths read as they always did.
|
||||||
// See wiki/concepts/shift.md.
|
// See wiki/concepts/shift.md.
|
||||||
|
|
||||||
export class ShiftAlreadyOpenError extends Error {
|
export class ShiftAlreadyOpenError extends Error {
|
||||||
/** The operator who currently holds the open shift (may be someone else). */
|
/** The operator who currently holds the open shift (may be someone else). */
|
||||||
readonly heldBy: string;
|
readonly heldBy: string;
|
||||||
constructor(operator: string, heldBy: string) {
|
constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
|
||||||
super(
|
super(
|
||||||
heldBy === operator
|
heldBy === operator
|
||||||
? `operator ${operator} already has an open shift`
|
? `operator ${operator} already has an open ${till} shift`
|
||||||
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`,
|
: `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
|
||||||
);
|
);
|
||||||
this.name = "ShiftAlreadyOpenError";
|
this.name = "ShiftAlreadyOpenError";
|
||||||
this.heldBy = heldBy;
|
this.heldBy = heldBy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class NoOpenShiftError extends Error {
|
export class NoOpenShiftError extends Error {
|
||||||
constructor(operator: string) {
|
constructor(operator: string, till: TillId = BOOTH_TILL) {
|
||||||
super(`operator ${operator} has no open shift`);
|
super(`operator ${operator} has no open ${till} shift`);
|
||||||
this.name = "NoOpenShiftError";
|
this.name = "NoOpenShiftError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Thrown by the booth money path when NO shift is open site-wide — an operator
|
/** Thrown by a money path when NO shift is open on its till — an operator must open
|
||||||
* must open a shift before any payment/exit can be attributed to a shift. */
|
* a shift there before any payment/exit can be attributed to one. */
|
||||||
export class NoShiftOpenError extends Error {
|
export class NoShiftOpenError extends Error {
|
||||||
constructor() {
|
readonly till: TillId;
|
||||||
super("no shift is open — open a shift before processing tickets");
|
constructor(till: TillId = BOOTH_TILL) {
|
||||||
|
super(
|
||||||
|
till === BOOTH_TILL
|
||||||
|
? "no shift is open — open a shift before processing tickets"
|
||||||
|
: `no ${till} shift is open — open one before taking money there`,
|
||||||
|
);
|
||||||
this.name = "NoShiftOpenError";
|
this.name = "NoShiftOpenError";
|
||||||
|
this.till = till;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
|
|||||||
export interface ShiftSummary {
|
export interface ShiftSummary {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
|
/** The till this shift reconciled (booth for every pre-till report). */
|
||||||
|
readonly till: TillId;
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
readonly startedAt: string;
|
readonly startedAt: string;
|
||||||
readonly endedAt: string;
|
readonly endedAt: string;
|
||||||
@@ -56,13 +71,21 @@ export interface ShiftSummary {
|
|||||||
readonly subscriptionSalesMinor: number;
|
readonly subscriptionSalesMinor: number;
|
||||||
readonly subscriptionWindowMinor: number;
|
readonly subscriptionWindowMinor: number;
|
||||||
readonly discountTotalMinor: number;
|
readonly discountTotalMinor: number;
|
||||||
|
readonly chargesByModuleMinor: ChargesByModule;
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
readonly cashAddedMinor: number;
|
readonly cashAddedMinor: number;
|
||||||
readonly cashRemovedMinor: number;
|
readonly cashRemovedMinor: number;
|
||||||
readonly expectedDrawerMinor: number;
|
readonly expectedDrawerMinor: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Module money folded into this till's payments as `chargeLines`, by owning module —
|
||||||
|
* a wash paid on the parking ticket lands here as `{ carwash: <minor> }`. Only modules
|
||||||
|
* that actually charged in the window appear. Cash+card already contain it; it is
|
||||||
|
* broken OUT of the ticket bucket so "Bileta" is parking money only. */
|
||||||
|
export type ChargesByModule = Partial<Record<ModuleId, number>>;
|
||||||
|
|
||||||
export interface ShiftReport {
|
export interface ShiftReport {
|
||||||
|
readonly till: TillId;
|
||||||
readonly operator: string;
|
readonly operator: string;
|
||||||
readonly startedAt: string;
|
readonly startedAt: string;
|
||||||
readonly endedAt: string;
|
readonly endedAt: string;
|
||||||
@@ -82,6 +105,8 @@ export interface ShiftReport {
|
|||||||
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
/** Merchant-validation DISCOUNT total given away in the window (leakage — the
|
||||||
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
* cash/card figures above are already NET of it). See validation-discounts.md. */
|
||||||
readonly discountTotalMinor: number;
|
readonly discountTotalMinor: number;
|
||||||
|
/** Module charges settled on this till's payments (a booth-paid wash), by module. */
|
||||||
|
readonly chargesByModuleMinor: ChargesByModule;
|
||||||
// --- Drawer (physical cash till; carries across shifts) ---
|
// --- Drawer (physical cash till; carries across shifts) ---
|
||||||
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
/** Cash in the drawer at shift start = prior shift's expected closing drawer. */
|
||||||
readonly openingFloatMinor: number;
|
readonly openingFloatMinor: number;
|
||||||
@@ -102,6 +127,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
|||||||
export interface DrawerMovement {
|
export interface DrawerMovement {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly type: "cash_in" | "cash_out";
|
readonly type: "cash_in" | "cash_out";
|
||||||
|
/** Which drawer the cash moved in/out of. */
|
||||||
|
readonly till: TillId;
|
||||||
/** Positive magnitude; direction is the `type`. */
|
/** Positive magnitude; direction is the `type`. */
|
||||||
readonly amountMinor: number;
|
readonly amountMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
@@ -122,6 +149,18 @@ export class InvalidCashMovementError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
|
||||||
|
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
|
||||||
|
/** The takings line a till's OWN money prints under (the booth sells tickets; the wash
|
||||||
|
* desk sells washes) and the label a module's charge gets when it rides another
|
||||||
|
* till's ticket ("Lavazh (në biletë)"). Printed slips are Albanian (i18n.md). */
|
||||||
|
const TILL_TAKINGS_LABEL: Record<TillId, string> = { booth: "Bileta", carwash: "Lavazh" };
|
||||||
|
const MODULE_PRINT_LABEL: Partial<Record<ModuleId, string>> = { carwash: "Lavazh", validation: "Validime" };
|
||||||
|
/** Which printer a till's slips (Z-report, vouchers) want. The wash desk falls back to
|
||||||
|
* the booth printer when it has none of its own (orderForRole); the booth never falls
|
||||||
|
* back to the desk. See wiki/concepts/printer-roles-failover.md. */
|
||||||
|
const TILL_PRINTER_ROLE: Record<TillId, PrinterRole> = { booth: "booth-receipt", carwash: "wash-desk" };
|
||||||
|
|
||||||
export class ShiftService {
|
export class ShiftService {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -133,41 +172,42 @@ export class ShiftService {
|
|||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Current physical drawer balance (cash payments + cash_movements, by time). For
|
/** Current physical drawer balance of a till (cash payments + cash_movements, by
|
||||||
* the UI to show "inherited / in the drawer now". */
|
* time). For the UI to show "inherited / in the drawer now". */
|
||||||
drawerBalance(): { balanceMinor: number; currency: string | null } {
|
drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
|
||||||
return this.#drawerBalanceAt(new Date().toISOString());
|
return this.#drawerBalanceAt(new Date().toISOString(), till);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
/** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
|
||||||
openShiftFor(operator: string) {
|
#shiftEvents(till: TillId) {
|
||||||
// Scan shift events for this operator; the shift is open if the most recent
|
return this.#db
|
||||||
// shift event for them is a `shift_open` (not yet closed by a z_report).
|
|
||||||
const rows = this.#db
|
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.identity, operator))
|
.where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
|
||||||
.orderBy(ledgerEvents.index)
|
.orderBy(ledgerEvents.index)
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
.filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is there an open shift for this operator on this till? Returns the open
|
||||||
|
* `shift_open` row or null. */
|
||||||
|
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
|
||||||
|
// The shift is open if the operator's most recent shift event on the till is a
|
||||||
|
// `shift_open` (not yet closed by a z_report).
|
||||||
|
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
|
||||||
const last = rows[rows.length - 1];
|
const last = rows[rows.length - 1];
|
||||||
return last && last.type === "shift_open" ? last : null;
|
return last && last.type === "shift_open" ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability
|
* The SINGLE open shift of a till, or null. A shift is the till's accountability
|
||||||
* period: at most ONE may be open at a time (so booth takings are unambiguously
|
* period: at most ONE may be open per till at a time (so its takings are
|
||||||
* attributed to one operator). It's open iff the most recent shift event on the
|
* unambiguously attributed to one operator). It's open iff the till's most recent
|
||||||
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been
|
* shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
|
||||||
* appended yet). Returns that row so callers can read its operator/startedAt.
|
* yet). Returns that row so callers can read its operator/startedAt.
|
||||||
*/
|
*/
|
||||||
currentOpenShift() {
|
currentOpenShift(till: TillId = BOOTH_TILL) {
|
||||||
const rows = this.#db
|
const rows = this.#shiftEvents(till);
|
||||||
.select()
|
|
||||||
.from(ledgerEvents)
|
|
||||||
.orderBy(ledgerEvents.index)
|
|
||||||
.all()
|
|
||||||
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
|
|
||||||
const last = rows[rows.length - 1];
|
const last = rows[rows.length - 1];
|
||||||
return last && last.type === "shift_open" ? last : null;
|
return last && last.type === "shift_open" ? last : null;
|
||||||
}
|
}
|
||||||
@@ -186,24 +226,25 @@ export class ShiftService {
|
|||||||
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
|
||||||
* for an operator that exists (the filter is an exact username match).
|
* for an operator that exists (the filter is an exact username match).
|
||||||
*/
|
*/
|
||||||
listOperators(): string[] {
|
listOperators(till?: TillId): string[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "shift_z_report"))
|
.where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
|
||||||
.all();
|
.all()
|
||||||
|
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
|
||||||
|
// Every operator with a closed report, plus the holder of each open shift (an
|
||||||
|
// open shift is the last shift_open on its till — but any shift_open's operator
|
||||||
|
// has or had a shift, which is all the dropdown needs).
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
|
||||||
if (op) names.add(op);
|
if (op) names.add(op);
|
||||||
}
|
}
|
||||||
const open = this.currentOpenShift();
|
|
||||||
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
|
|
||||||
if (openOp) names.add(openOp);
|
|
||||||
return [...names].sort((a, b) => a.localeCompare(b));
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
|
listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
@@ -225,6 +266,7 @@ export class ShiftService {
|
|||||||
subscriptionSalesMinor?: number;
|
subscriptionSalesMinor?: number;
|
||||||
subscriptionWindowMinor?: number;
|
subscriptionWindowMinor?: number;
|
||||||
discountTotalMinor?: number;
|
discountTotalMinor?: number;
|
||||||
|
chargesByModuleMinor?: ChargesByModule;
|
||||||
openingFloatMinor?: number;
|
openingFloatMinor?: number;
|
||||||
cashAddedMinor?: number;
|
cashAddedMinor?: number;
|
||||||
cashRemovedMinor?: number;
|
cashRemovedMinor?: number;
|
||||||
@@ -232,12 +274,15 @@ export class ShiftService {
|
|||||||
};
|
};
|
||||||
const operator = pl.operator ?? r.identity ?? "?";
|
const operator = pl.operator ?? r.identity ?? "?";
|
||||||
const startedAt = pl.startedAt ?? r.occurredAt;
|
const startedAt = pl.startedAt ?? r.occurredAt;
|
||||||
|
const till = tillOf(pl);
|
||||||
|
if (opts.till && till !== opts.till) continue;
|
||||||
if (opts.operator && operator !== opts.operator) continue;
|
if (opts.operator && operator !== opts.operator) continue;
|
||||||
if (opts.from && startedAt < opts.from) continue;
|
if (opts.from && startedAt < opts.from) continue;
|
||||||
if (opts.to && startedAt > opts.to) continue;
|
if (opts.to && startedAt > opts.to) continue;
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
index: r.index,
|
index: r.index,
|
||||||
|
till,
|
||||||
operator,
|
operator,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: pl.endedAt ?? r.occurredAt,
|
endedAt: pl.endedAt ?? r.occurredAt,
|
||||||
@@ -257,6 +302,8 @@ export class ShiftService {
|
|||||||
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
(pl.cashTotalMinor ?? 0) + (pl.cardTotalMinor ?? 0) - (pl.subscriptionTotalMinor ?? 0),
|
||||||
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
// Merchant-validation leakage (added 2026-07-13). Old reports lack it → 0.
|
||||||
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
discountTotalMinor: pl.discountTotalMinor ?? 0,
|
||||||
|
// Module charges on the ticket (added 2026-09-06). Old reports lack it → none.
|
||||||
|
chargesByModuleMinor: pl.chargesByModuleMinor ?? {},
|
||||||
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
openingFloatMinor: pl.openingFloatMinor ?? 0,
|
||||||
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
cashAddedMinor: pl.cashAddedMinor ?? 0,
|
||||||
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
cashRemovedMinor: pl.cashRemovedMinor ?? 0,
|
||||||
@@ -267,10 +314,10 @@ export class ShiftService {
|
|||||||
return out.reverse();
|
return out.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Require an open shift for the booth money path; returns it or throws. */
|
/** Require an open shift on a till for its money path; returns it or throws. */
|
||||||
requireOpenShift() {
|
requireOpenShift(till: TillId = BOOTH_TILL) {
|
||||||
const open = this.currentOpenShift();
|
const open = this.currentOpenShift(till);
|
||||||
if (!open) throw new NoShiftOpenError();
|
if (!open) throw new NoShiftOpenError(till);
|
||||||
return open;
|
return open;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,9 +330,10 @@ export class ShiftService {
|
|||||||
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
|
||||||
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
|
||||||
* removal) — historical chain events that still fold in unchanged.
|
* removal) — historical chain events that still fold in unchanged.
|
||||||
* This is what carries across shifts.
|
* This is what carries across shifts. ONE till: every money event is filtered by
|
||||||
|
* `tillOf(payload)` (absent = booth).
|
||||||
*/
|
*/
|
||||||
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
|
#drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
@@ -295,16 +343,20 @@ export class ShiftService {
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.occurredAt <= at &&
|
r.occurredAt <= at &&
|
||||||
(r.type === "payment" ||
|
(r.type === "payment" ||
|
||||||
|
// Car Wash module: money taken at the bay (cash adds to the drawer, card
|
||||||
|
// never does — same tender rule as a parking payment).
|
||||||
|
r.type === "carwash_payment" ||
|
||||||
r.type === "cash_in" ||
|
r.type === "cash_in" ||
|
||||||
r.type === "cash_out" ||
|
r.type === "cash_out" ||
|
||||||
r.type === "cash_movement"),
|
r.type === "cash_movement") &&
|
||||||
|
tillOf(r.payload as LedgerPayload | null) === till,
|
||||||
);
|
);
|
||||||
let balanceMinor = 0;
|
let balanceMinor = 0;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
if (r.type === "payment") {
|
if (r.type === "payment" || r.type === "carwash_payment") {
|
||||||
// Only CASH enters the till; card settles to the bank.
|
// Only CASH enters the till; card settles to the bank.
|
||||||
if (pl.tender !== "card") balanceMinor += amt;
|
if (pl.tender !== "card") balanceMinor += amt;
|
||||||
} else if (r.type === "cash_in") {
|
} else if (r.type === "cash_in") {
|
||||||
@@ -347,8 +399,11 @@ export class ShiftService {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
/** Which drawer the cash moved in/out of (default: the booth). */
|
||||||
|
till?: TillId;
|
||||||
|
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
|
||||||
const { type, operator, reason } = args;
|
const { type, operator, reason } = args;
|
||||||
|
const till = args.till ?? BOOTH_TILL;
|
||||||
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
|
||||||
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
|
||||||
}
|
}
|
||||||
@@ -365,15 +420,16 @@ export class ShiftService {
|
|||||||
...(args.currency ? { currency: args.currency } : {}),
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
operator,
|
operator,
|
||||||
voucherNo,
|
voucherNo,
|
||||||
|
till,
|
||||||
},
|
},
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
});
|
});
|
||||||
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
|
const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
|
||||||
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now });
|
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
`${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
|
||||||
);
|
);
|
||||||
return { type, amountMinor, voucherNo, balanceMinor, printed };
|
return { type, till, amountMinor, voucherNo, balanceMinor, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,7 +487,7 @@ export class ShiftService {
|
|||||||
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
* review queue. `operator` (optional) scopes to one operator's movements (an operator
|
||||||
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] {
|
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
|
||||||
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
|
||||||
// Latest review decision per movement id.
|
// Latest review decision per movement id.
|
||||||
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||||
@@ -452,12 +508,15 @@ export class ShiftService {
|
|||||||
const pl = (r.payload ?? {}) as LedgerPayload;
|
const pl = (r.payload ?? {}) as LedgerPayload;
|
||||||
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
|
||||||
if (filter?.operator && operator !== filter.operator) continue;
|
if (filter?.operator && operator !== filter.operator) continue;
|
||||||
|
const till = tillOf(pl);
|
||||||
|
if (filter?.till && till !== filter.till) continue;
|
||||||
const review = reviewByRef.get(r.id);
|
const review = reviewByRef.get(r.id);
|
||||||
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
|
||||||
if (filter?.status && status !== filter.status) continue;
|
if (filter?.status && status !== filter.status) continue;
|
||||||
out.push({
|
out.push({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
till,
|
||||||
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
|
||||||
currency: pl.currency ?? null,
|
currency: pl.currency ?? null,
|
||||||
reason: pl.reason ?? null,
|
reason: pl.reason ?? null,
|
||||||
@@ -474,27 +533,28 @@ export class ShiftService {
|
|||||||
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a shift for the operator (explicit start). The opening float is auto-
|
/** Open a shift for the operator on a till (explicit start). The opening float is
|
||||||
* inherited from the chain = the drawer balance at the start instant. */
|
* auto-inherited from the chain = that till's drawer balance at the start instant. */
|
||||||
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||||
// Site-wide single-open invariant: refuse if ANY shift is open — whether this
|
// Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
|
||||||
// operator's own (double-open) or another operator's (handover not done). Only
|
// this operator's own (double-open) or another operator's (handover not done).
|
||||||
// one accountability period at a time.
|
// One accountability period per drawer at a time. (Another till's shift is
|
||||||
const current = this.currentOpenShift();
|
// independent: the booth and the wash desk run side by side.)
|
||||||
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator);
|
const current = this.currentOpenShift(till);
|
||||||
|
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_open",
|
type: "shift_open",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
identity: operator, // the shift's operator; `identity` keys the shift to them
|
||||||
// Record the inherited opening float on the shift_open so it's reproducible
|
// Record the inherited opening float on the shift_open so it's reproducible
|
||||||
// and the next operator's handover figure is fixed in the chain.
|
// and the next operator's handover figure is fixed in the chain.
|
||||||
payload: { operator, openingFloatMinor },
|
payload: { operator, openingFloatMinor, till },
|
||||||
occurredAt: startedAt,
|
occurredAt: startedAt,
|
||||||
});
|
});
|
||||||
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||||
return { startedAt, openingFloatMinor };
|
return { startedAt, till, openingFloatMinor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -510,15 +570,22 @@ export class ShiftService {
|
|||||||
): Omit<ShiftReport, "printed"> {
|
): Omit<ShiftReport, "printed"> {
|
||||||
const operator = open.identity ?? "?";
|
const operator = open.identity ?? "?";
|
||||||
const startedAt = open.occurredAt;
|
const startedAt = open.occurredAt;
|
||||||
|
const till = tillOf(open.payload as LedgerPayload | null);
|
||||||
|
|
||||||
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
|
// All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
|
||||||
// the operator who handled the money (decision: sum by payment time).
|
// time = the operator who handled the money (decision: sum by payment time).
|
||||||
const payments = this.#db
|
const payments = this.#db
|
||||||
.select()
|
.select()
|
||||||
.from(ledgerEvents)
|
.from(ledgerEvents)
|
||||||
.where(eq(ledgerEvents.type, "payment"))
|
// Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
|
||||||
|
// parking payment's amount already, as chargeLines). Both fold into the cash/card
|
||||||
|
// tender totals so the expected drawer is right; the booth-paid wash is then
|
||||||
|
// broken OUT of the ticket bucket into chargesByModuleMinor (see below).
|
||||||
|
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
|
||||||
.all()
|
.all()
|
||||||
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
|
.filter(
|
||||||
|
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
|
||||||
|
);
|
||||||
|
|
||||||
let cashTotalMinor = 0;
|
let cashTotalMinor = 0;
|
||||||
let cardTotalMinor = 0;
|
let cardTotalMinor = 0;
|
||||||
@@ -532,11 +599,16 @@ export class ShiftService {
|
|||||||
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
// Merchant-validation leakage: Σ discountMinor across the window's payments. The
|
||||||
// tender totals are already NET; this is the "given away" figure beside them.
|
// tender totals are already NET; this is the "given away" figure beside them.
|
||||||
let discountTotalMinor = 0;
|
let discountTotalMinor = 0;
|
||||||
|
// Module charges folded into this till's payments (chargeLines on a booth payment),
|
||||||
|
// summed by owning module. Part of cash/card; NOT ticket money.
|
||||||
|
const chargesByModuleMinor: ChargesByModule = {};
|
||||||
|
let chargesTotalMinor = 0;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
for (const p of payments) {
|
for (const p of payments) {
|
||||||
const pl = (p.payload ?? {}) as LedgerPayload & {
|
const pl = (p.payload ?? {}) as LedgerPayload & {
|
||||||
subscriptionSale?: boolean;
|
subscriptionSale?: boolean;
|
||||||
subscriptionWindowCharge?: boolean;
|
subscriptionWindowCharge?: boolean;
|
||||||
|
chargeLines?: ChargeLine[];
|
||||||
};
|
};
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
if (pl.tender === "card") cardTotalMinor += amt;
|
if (pl.tender === "card") cardTotalMinor += amt;
|
||||||
@@ -545,10 +617,17 @@ export class ShiftService {
|
|||||||
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
else if (pl.subscriptionWindowCharge === true) subscriptionWindowMinor += amt;
|
||||||
// (else → transient ticket; derived below as total − subscription)
|
// (else → transient ticket; derived below as total − subscription)
|
||||||
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
if (typeof pl.discountMinor === "number") discountTotalMinor += pl.discountMinor;
|
||||||
|
for (const l of pl.chargeLines ?? []) {
|
||||||
|
if (typeof l.amountMinor !== "number" || !l.module) continue;
|
||||||
|
chargesByModuleMinor[l.module] = (chargesByModuleMinor[l.module] ?? 0) + l.amountMinor;
|
||||||
|
chargesTotalMinor += l.amountMinor;
|
||||||
|
}
|
||||||
if (pl.currency) currency = pl.currency;
|
if (pl.currency) currency = pl.currency;
|
||||||
}
|
}
|
||||||
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
const subscriptionTotalMinor = subscriptionSalesMinor + subscriptionWindowMinor;
|
||||||
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor;
|
// Ticket = what is left once subscriber money and module charges are taken out:
|
||||||
|
// ticket + subscriptions + Σcharges = cash + card, always.
|
||||||
|
const ticketTotalMinor = cashTotalMinor + cardTotalMinor - subscriptionTotalMinor - chargesTotalMinor;
|
||||||
|
|
||||||
// --- Drawer figures ---
|
// --- Drawer figures ---
|
||||||
// Opening float was fixed on shift_open (inherited from the chain at start);
|
// Opening float was fixed on shift_open (inherited from the chain at start);
|
||||||
@@ -557,7 +636,7 @@ export class ShiftService {
|
|||||||
const openingFloatMinor =
|
const openingFloatMinor =
|
||||||
typeof openPl.openingFloatMinor === "number"
|
typeof openPl.openingFloatMinor === "number"
|
||||||
? openPl.openingFloatMinor
|
? openPl.openingFloatMinor
|
||||||
: this.#drawerBalanceAt(startedAt).balanceMinor;
|
: this.#drawerBalanceAt(startedAt, till).balanceMinor;
|
||||||
|
|
||||||
// Drawer movements within the window, split into added (+) and removed (−).
|
// Drawer movements within the window, split into added (+) and removed (−).
|
||||||
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
|
||||||
@@ -570,7 +649,8 @@ export class ShiftService {
|
|||||||
(r) =>
|
(r) =>
|
||||||
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
|
||||||
r.occurredAt >= startedAt &&
|
r.occurredAt >= startedAt &&
|
||||||
r.occurredAt <= asOf,
|
r.occurredAt <= asOf &&
|
||||||
|
tillOf(r.payload as LedgerPayload | null) === till,
|
||||||
);
|
);
|
||||||
let cashAddedMinor = 0;
|
let cashAddedMinor = 0;
|
||||||
let cashRemovedMinor = 0;
|
let cashRemovedMinor = 0;
|
||||||
@@ -589,6 +669,7 @@ export class ShiftService {
|
|||||||
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
till,
|
||||||
operator,
|
operator,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt: asOf,
|
endedAt: asOf,
|
||||||
@@ -601,6 +682,7 @@ export class ShiftService {
|
|||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
discountTotalMinor,
|
discountTotalMinor,
|
||||||
|
chargesByModuleMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -615,17 +697,18 @@ export class ShiftService {
|
|||||||
* projection the Z-report prints, so the operator sees exactly what their close
|
* projection the Z-report prints, so the operator sees exactly what their close
|
||||||
* will show. See wiki/concepts/shift.md.
|
* will show. See wiki/concepts/shift.md.
|
||||||
*/
|
*/
|
||||||
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||||
const open = this.currentOpenShift();
|
const open = this.currentOpenShift(till);
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
const asOf = new Date().toISOString();
|
const asOf = new Date().toISOString();
|
||||||
return { ...this.#summariseWindow(open, asOf), asOf };
|
return { ...this.#summariseWindow(open, asOf), asOf };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
/** Close the operator's open shift on a till: sum its payments in the window, sign +
|
||||||
async close(operator: string): Promise<ShiftReport> {
|
* print the Z-report. */
|
||||||
const open = this.openShiftFor(operator);
|
async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
|
||||||
if (!open) throw new NoOpenShiftError(operator);
|
const open = this.openShiftFor(operator, till);
|
||||||
|
if (!open) throw new NoOpenShiftError(operator, till);
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
|
|
||||||
const report = this.#summariseWindow(open, endedAt);
|
const report = this.#summariseWindow(open, endedAt);
|
||||||
@@ -640,6 +723,7 @@ export class ShiftService {
|
|||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
discountTotalMinor,
|
discountTotalMinor,
|
||||||
|
chargesByModuleMinor,
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -652,6 +736,7 @@ export class ShiftService {
|
|||||||
identity: operator,
|
identity: operator,
|
||||||
payload: {
|
payload: {
|
||||||
operator,
|
operator,
|
||||||
|
till,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
cashTotalMinor,
|
cashTotalMinor,
|
||||||
@@ -663,6 +748,8 @@ export class ShiftService {
|
|||||||
subscriptionSalesMinor,
|
subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor,
|
subscriptionWindowMinor,
|
||||||
discountTotalMinor,
|
discountTotalMinor,
|
||||||
|
// Only when a module charged in the window (older slips/payloads stay identical).
|
||||||
|
...(Object.keys(chargesByModuleMinor).length ? { chargesByModuleMinor } : {}),
|
||||||
openingFloatMinor,
|
openingFloatMinor,
|
||||||
cashAddedMinor,
|
cashAddedMinor,
|
||||||
cashRemovedMinor,
|
cashRemovedMinor,
|
||||||
@@ -673,25 +760,23 @@ export class ShiftService {
|
|||||||
const printed = await this.#printZReport(report);
|
const printed = await this.#printZReport(report);
|
||||||
|
|
||||||
this.#logger.info(
|
this.#logger.info(
|
||||||
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
`${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
|
||||||
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
|
||||||
);
|
);
|
||||||
return { ...report, printed };
|
return { ...report, printed };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
/** Print the Z-report on the till's printer (best-effort; the signed event is the
|
||||||
* is the record — a failed print doesn't undo the close). */
|
* record — a failed print doesn't undo the close). */
|
||||||
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
async #printZReport(r: Omit<ShiftReport, "printed">): Promise<boolean> {
|
||||||
const printer = await this.#boothPrinter();
|
|
||||||
if (!printer) {
|
|
||||||
this.#logger.warn(`no booth-receipt printer — Z-report for ${r.operator} not printed (event is recorded)`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const cur = r.currency ?? "";
|
const cur = r.currency ?? "";
|
||||||
const money = (m: number) => (m / 100).toFixed(2);
|
const money = (m: number) => (m / 100).toFixed(2);
|
||||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||||||
const lines = [
|
const lines = [
|
||||||
|
// Which drawer this report reconciles — only printed off the booth, so booth
|
||||||
|
// slips stay byte-identical to before tills existed.
|
||||||
|
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
|
||||||
`Operatori: ${r.operator}`,
|
`Operatori: ${r.operator}`,
|
||||||
`Nga: ${zStamp(r.startedAt)}`,
|
`Nga: ${zStamp(r.startedAt)}`,
|
||||||
`Deri: ${zStamp(r.endedAt)}`,
|
`Deri: ${zStamp(r.endedAt)}`,
|
||||||
@@ -701,11 +786,23 @@ export class ShiftService {
|
|||||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||||
"",
|
"",
|
||||||
"-- Arkëtime sipas burimit --",
|
"-- Arkëtime sipas burimit --",
|
||||||
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
// The booth prints its three classic lines (byte-identical to before tills); a
|
||||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
// module's till prints its own takings under its own name — it sells no tickets
|
||||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
// and no subscriptions.
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
...(r.till === BOOTH_TILL
|
||||||
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
? [
|
||||||
|
`Bileta: ${money(r.ticketTotalMinor)} ${cur}`,
|
||||||
|
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||||
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
|
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
|
]
|
||||||
|
: [`${TILL_TAKINGS_LABEL[r.till]}: ${money(r.ticketTotalMinor)} ${cur}`]),
|
||||||
|
// Module money that rode this till's tickets (a booth-paid wash) — its own line,
|
||||||
|
// only when any was taken, so the operator sees parking and wash money apart.
|
||||||
|
...Object.entries(r.chargesByModuleMinor)
|
||||||
|
.filter(([, v]) => (v ?? 0) > 0)
|
||||||
|
.map(([m, v]) => `${MODULE_PRINT_LABEL[m as ModuleId] ?? m} (në biletë): ${money(v ?? 0)} ${cur}`),
|
||||||
// Merchant-validation leakage — printed only when the shift actually gave any
|
// Merchant-validation leakage — printed only when the shift actually gave any
|
||||||
// (older slips stay byte-identical). The takings above are already NET of it.
|
// (older slips stay byte-identical). The takings above are already NET of it.
|
||||||
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
...(r.discountTotalMinor > 0 ? [`Zbritje (validime): ${money(r.discountTotalMinor)} ${cur}`] : []),
|
||||||
@@ -717,13 +814,7 @@ export class ShiftService {
|
|||||||
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
return this.#printOn(r.till, `Z-report for ${r.operator}`, (p) => p.printReport({ title: "RAPORT TURNI", lines }));
|
||||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
this.#logger.warn(`Z-report print failed for ${r.operator}: ${(err as Error).message} (event recorded)`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
|
||||||
@@ -737,17 +828,14 @@ export class ShiftService {
|
|||||||
operator: string;
|
operator: string;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
at: string;
|
at: string;
|
||||||
|
till: TillId;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const printer = await this.#boothPrinter();
|
|
||||||
if (!printer) {
|
|
||||||
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const cur = v.currency ?? "";
|
const cur = v.currency ?? "";
|
||||||
const money = (m: number) => (m / 100).toFixed(2);
|
const money = (m: number) => (m / 100).toFixed(2);
|
||||||
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
|
||||||
const lines = [
|
const lines = [
|
||||||
`Mandat Nr.: ${v.voucherNo}`,
|
`Mandat Nr.: ${v.voucherNo}`,
|
||||||
|
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
|
||||||
`Data: ${zStamp(v.at)}`,
|
`Data: ${zStamp(v.at)}`,
|
||||||
"",
|
"",
|
||||||
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
`Shuma: ${money(v.amountMinor)} ${cur}`,
|
||||||
@@ -755,27 +843,57 @@ export class ShiftService {
|
|||||||
"",
|
"",
|
||||||
`Regjistroi: ${v.operator}`,
|
`Regjistroi: ${v.operator}`,
|
||||||
];
|
];
|
||||||
try {
|
return this.#printOn(v.till, `${v.type} ${v.voucherNo}`, (p) => p.printReport({ title, lines }));
|
||||||
await printer.printReport({ title, lines });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** First enabled booth-receipt printer, or any enabled printer. */
|
/** Print a till's slip on its printer with failover (wash desk → booth printer;
|
||||||
async #boothPrinter(): Promise<PrinterDevice | null> {
|
* see TILL_PRINTER_ROLE / orderForRole). Best-effort: the signed event is the
|
||||||
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
* record — every failure is logged and reported as "not printed", never thrown.
|
||||||
const enabled = rows.filter((r) => r.enabled);
|
* Legacy fallback: a site whose only printer carries no booth role (one unit,
|
||||||
const booth = enabled.find((r) => (r.config as { role?: string }).role === "booth-receipt") ?? enabled[0];
|
* configured as the entry dispenser) still prints its slips on it, as before. */
|
||||||
if (!booth) return null;
|
async #printOn(till: TillId, what: string, job: (p: PrinterDevice) => Promise<void>): Promise<boolean> {
|
||||||
const driver = registry.get(booth.driverId);
|
const printers = this.#loadPrinters();
|
||||||
if (!driver) return null;
|
const want = TILL_PRINTER_ROLE[till];
|
||||||
try {
|
let ordered = orderForRole(printers, want);
|
||||||
return driver.create(booth.config as never) as PrinterDevice;
|
if (ordered.length === 0 && till === BOOTH_TILL) ordered = printers.slice(0, 1);
|
||||||
} catch {
|
if (ordered.length === 0) {
|
||||||
return null;
|
this.#logger.warn(`no ${want} printer — ${what} not printed (event is recorded)`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
const attempts: string[] = [];
|
||||||
|
for (const p of ordered) {
|
||||||
|
try {
|
||||||
|
await job(p.device);
|
||||||
|
if (p.role !== want) this.#logger.info(`${what} printed on ${p.id} (${p.role}; no ${want} printer reachable)`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
attempts.push(`${p.id} (${(err as Error).message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#logger.warn(`${what} print failed on every candidate: ${attempts.join(", ")} (event recorded)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every enabled printer as a live instance (role + rank from its saved config). */
|
||||||
|
#loadPrinters(): PrinterInstance[] {
|
||||||
|
const rows = this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
|
||||||
|
const out: PrinterInstance[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.enabled) continue;
|
||||||
|
const driver = registry.get(row.driverId);
|
||||||
|
if (!driver) continue;
|
||||||
|
const cfg = row.config as Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
out.push({
|
||||||
|
id: row.id,
|
||||||
|
role: printerRoleOf(cfg),
|
||||||
|
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
||||||
|
device: driver.create(cfg as never) as PrinterDevice,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip a printer whose config won't build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,22 +167,42 @@ 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();
|
// Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
|
||||||
if (!plate) return;
|
// downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
|
||||||
|
// vehicle out of whatever copy survives and blur the plate inside it.
|
||||||
|
const frame = await frameSize(shot.bytes);
|
||||||
|
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
|
||||||
|
b && frame
|
||||||
|
? {
|
||||||
|
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
|
||||||
|
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
// The vehicle's body type (advisory; the wash desk's category suggestion — see
|
||||||
|
// 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 vehicleBox = norm(result.vehicle?.bbox);
|
||||||
|
const vehicle = result.vehicle
|
||||||
|
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
|
||||||
|
: {};
|
||||||
|
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
|
||||||
|
const plateBox = plate ? norm(result.plate?.bbox) : null;
|
||||||
|
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, ...(plateBox ? { plateBox } : {}) }
|
||||||
region: result.plate.region ?? null,
|
: {}),
|
||||||
|
...vehicle,
|
||||||
modelVersion: result.modelVersion,
|
modelVersion: result.modelVersion,
|
||||||
snapshotId,
|
snapshotId,
|
||||||
source: "entry-exit-snapshot",
|
source: "entry-exit-snapshot",
|
||||||
@@ -190,7 +210,18 @@ 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 (vehicleBox) {
|
||||||
|
deviceEvents.emitVehicleRead({
|
||||||
|
identity,
|
||||||
|
direction,
|
||||||
|
read: { bodyType: result.vehicle.bodyType, confidence: result.vehicle.confidence, snapshotId, box: vehicleBox, plateBox },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 });
|
||||||
@@ -206,6 +237,20 @@ async function recognizePlate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
|
||||||
|
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
|
||||||
|
try {
|
||||||
|
const m = await sharp(bytes, { failOn: "none" }).metadata();
|
||||||
|
return m.width && m.height ? { w: m.width, h: m.height } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||||
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||||
* re-pressing within minutes; a long window would flag legit re-visits. */
|
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { eq, ledgerEvents, type Db } from "@parking/db";
|
import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db";
|
||||||
|
import type { EventLog } from "./event-log.js";
|
||||||
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
import type { SessionValidation, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
|
||||||
@@ -86,3 +87,170 @@ export function sessionValidations(db: Db, identity: string): AppliedValidation[
|
|||||||
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
|
||||||
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Apply (shared by the merchant route and the Car Wash module) ----------------
|
||||||
|
|
||||||
|
export interface ApplyValidationInput {
|
||||||
|
programId: string;
|
||||||
|
identity: string;
|
||||||
|
/** Username recorded as the applying operator. */
|
||||||
|
actor: string;
|
||||||
|
/** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */
|
||||||
|
amountMinor?: number;
|
||||||
|
/** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which
|
||||||
|
* are RESOLVED here into a plain timeCredit / fixed event the pricing fold already
|
||||||
|
* understands: `washMinutes` = the wash window (order intake → done), NOT the whole
|
||||||
|
* stay; `priceMinor` = the wash price. */
|
||||||
|
wash?: { washMinutes: number; priceMinor: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApplyValidationResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
eventId: string;
|
||||||
|
programId: string;
|
||||||
|
label: string;
|
||||||
|
mode: string;
|
||||||
|
minutes?: number | null;
|
||||||
|
percent?: number | null;
|
||||||
|
amountMinor?: number;
|
||||||
|
}
|
||||||
|
| { ok: false; status: 400 | 404 | 409; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a validation program to an open transient session and append the signed
|
||||||
|
* `validation` event with the RESOLVED values. The decision chain, in order: program
|
||||||
|
* live + active → open TRANSIENT session → not already carrying a live application of
|
||||||
|
* this program → per-day cap → fixed-amount bounds. The merchant route adds its own
|
||||||
|
* program↔user BINDING check before calling this; a module applying its own program
|
||||||
|
* (Car Wash sponsorship) has no binding — the actor is attributed on the event instead.
|
||||||
|
* Returns a result object rather than throwing so each caller maps to its own HTTP
|
||||||
|
* shape. See wiki/concepts/validation-discounts.md.
|
||||||
|
*/
|
||||||
|
export async function applyValidation(
|
||||||
|
db: Db,
|
||||||
|
eventLog: EventLog,
|
||||||
|
input: ApplyValidationInput,
|
||||||
|
): Promise<ApplyValidationResult> {
|
||||||
|
const { programId, identity, actor } = input;
|
||||||
|
const program = db
|
||||||
|
.select()
|
||||||
|
.from(validationPrograms)
|
||||||
|
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
|
||||||
|
.get();
|
||||||
|
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.identity, identity))
|
||||||
|
.orderBy(ledgerEvents.index)
|
||||||
|
.all();
|
||||||
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
|
if (!entry) return { ok: false, status: 404, error: "no session for ticket" };
|
||||||
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
|
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||||
|
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
|
||||||
|
}
|
||||||
|
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||||
|
return { ok: false, status: 409, error: "session is closed" };
|
||||||
|
}
|
||||||
|
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||||
|
return { ok: false, status: 409, error: "this program is already applied to the ticket" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
||||||
|
// appliance runs in site time).
|
||||||
|
if (program.maxPerDay != null) {
|
||||||
|
const midnight = new Date();
|
||||||
|
midnight.setHours(0, 0, 0, 0);
|
||||||
|
const todays = db
|
||||||
|
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(eq(ledgerEvents.type, "validation"))
|
||||||
|
.all()
|
||||||
|
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
||||||
|
const voidedIds = new Set(
|
||||||
|
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
||||||
|
);
|
||||||
|
const count = todays.filter((r) => {
|
||||||
|
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
||||||
|
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
||||||
|
}).length;
|
||||||
|
if (count >= program.maxPerDay) return { ok: false, status: 409, error: "daily cap reached for this program" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
|
||||||
|
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
|
||||||
|
// on the signed event for audit.
|
||||||
|
let mode: "comp" | "timeCredit" | "fixed" | "percent";
|
||||||
|
let minutes: number | undefined;
|
||||||
|
let percent: number | undefined;
|
||||||
|
let amountMinor: number | undefined;
|
||||||
|
switch (program.mode) {
|
||||||
|
case "comp":
|
||||||
|
mode = "comp";
|
||||||
|
break;
|
||||||
|
case "timeCredit":
|
||||||
|
mode = "timeCredit";
|
||||||
|
minutes = program.minutes ?? undefined;
|
||||||
|
break;
|
||||||
|
case "percent":
|
||||||
|
mode = "percent";
|
||||||
|
percent = program.percent ?? undefined;
|
||||||
|
break;
|
||||||
|
case "fixed": {
|
||||||
|
const a = input.amountMinor;
|
||||||
|
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||||
|
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
|
||||||
|
}
|
||||||
|
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||||
|
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
|
||||||
|
}
|
||||||
|
mode = "fixed";
|
||||||
|
amountMinor = a;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "doneTolerance": {
|
||||||
|
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
|
||||||
|
mode = "timeCredit";
|
||||||
|
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "washPrice": {
|
||||||
|
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
|
||||||
|
mode = "fixed";
|
||||||
|
amountMinor = Math.max(0, input.wash.priceMinor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = await eventLog.append({
|
||||||
|
type: "validation",
|
||||||
|
source: "manual",
|
||||||
|
identity,
|
||||||
|
payload: {
|
||||||
|
sessionRef: identity,
|
||||||
|
programId,
|
||||||
|
programLabel: program.name,
|
||||||
|
mode,
|
||||||
|
...(program.mode !== mode ? { programMode: program.mode } : {}),
|
||||||
|
...(minutes != null ? { minutes } : {}),
|
||||||
|
...(percent != null ? { percent } : {}),
|
||||||
|
...(amountMinor != null ? { amountMinor } : {}),
|
||||||
|
operator: actor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
eventId: ev.id,
|
||||||
|
programId,
|
||||||
|
label: program.name,
|
||||||
|
mode,
|
||||||
|
minutes,
|
||||||
|
percent,
|
||||||
|
amountMinor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,21 @@ 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 vehicle's box in frame pixels, when the stage found one. */
|
||||||
|
readonly bbox?: PlateBBox | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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; bbox?: PlateBBox | 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 +72,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 +137,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)), bbox: v.bbox ?? null }
|
||||||
|
: 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,3 +20,12 @@ VISION_OCR_MODEL=cct-xs-v2-global-model
|
|||||||
# Confidence floor — a best plate below this is flagged low_confidence so the Node side
|
# Confidence floor — a best plate below this is flagged low_confidence so the Node side
|
||||||
# treats it as advisory and falls back to the ticket path. Keep in sync with the server.
|
# treats it as advisory and falls back to the ticket path. Keep in sync with the server.
|
||||||
VISION_MIN_CONFIDENCE=0.5
|
VISION_MIN_CONFIDENCE=0.5
|
||||||
|
|
||||||
|
# Vehicle stage (phase A): a YOLOX ONNX graph (Apache-2.0) run on the same frame after the
|
||||||
|
# plate read; fills /analyze `vehicle.body_type` (car/truck/bus/motorcycle) + confidence for
|
||||||
|
# the Car Wash desk's category suggestion. Unset = off. The Docker image bakes the weights
|
||||||
|
# at /app/models/yolox_s.onnx; locally: curl the release file into apps/vision/models/.
|
||||||
|
# https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
|
||||||
|
# VISION_VEHICLE_MODEL_PATH=models/yolox_s.onnx
|
||||||
|
# VISION_VEHICLE_INPUT_SIZE=640
|
||||||
|
# VISION_VEHICLE_MIN_CONFIDENCE=0.4
|
||||||
|
|||||||
+10
-2
@@ -14,7 +14,7 @@ ENV UV_LINK_MODE=copy \
|
|||||||
|
|
||||||
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
|
# System libs the recognizer stack needs (opencv/onnxruntime): GL + glib. Kept minimal.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 \
|
&& apt-get install -y --no-install-recommends libgl1 libglib2.0-0 curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
|
# ---- deps: resolve + install the venv from the lockfile (cache-friendly) ----
|
||||||
@@ -26,6 +26,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
# ---- project source ----
|
# ---- project source ----
|
||||||
COPY vision_service/ ./vision_service/
|
COPY vision_service/ ./vision_service/
|
||||||
COPY README.md ./
|
COPY README.md ./
|
||||||
|
# Vehicle stage weights (phase A): YOLOX-S, Apache-2.0, ~36 MB, baked into the image so the
|
||||||
|
# air-gapped appliance never fetches at runtime and no operator-writable path holds a model
|
||||||
|
# (vision-service-hardening.md). Best-effort at build: without network the stage stays off.
|
||||||
|
ARG YOLOX_URL=https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx
|
||||||
|
RUN mkdir -p /app/models \
|
||||||
|
&& (curl -fsSL -o /app/models/yolox_s.onnx "$YOLOX_URL" \
|
||||||
|
|| (echo "[build] yolox weights not fetched (no network) — vehicle stage off" && rm -f /app/models/yolox_s.onnx))
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --extra alpr
|
uv sync --frozen --extra alpr
|
||||||
|
|
||||||
@@ -49,7 +56,8 @@ RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
|
|||||||
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
|
# Default to the stub recognizer (offline, no model load); override to fast_alpr in prod.
|
||||||
ENV VISION_RECOGNIZER=stub \
|
ENV VISION_RECOGNIZER=stub \
|
||||||
VISION_HOST=0.0.0.0 \
|
VISION_HOST=0.0.0.0 \
|
||||||
VISION_PORT=8089
|
VISION_PORT=8089 \
|
||||||
|
VISION_VEHICLE_MODEL_PATH=/app/models/yolox_s.onnx
|
||||||
EXPOSE 8089
|
EXPOSE 8089
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
|
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ dev = [
|
|||||||
"pytest>=8.3",
|
"pytest>=8.3",
|
||||||
"httpx>=0.27", # FastAPI TestClient transport
|
"httpx>=0.27", # FastAPI TestClient transport
|
||||||
"mypy>=1.13",
|
"mypy>=1.13",
|
||||||
|
# The vehicle stage's post-processing tests run on synthetic tensors without the model
|
||||||
|
# stack (CI syncs WITHOUT the alpr extra); the service itself imports numpy lazily.
|
||||||
|
"numpy>=1.26",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Vehicle stage (phase A) — pure post-processing on synthetic tensors, and the
|
||||||
|
recognizer composition over the stub with a fake detector. No weights needed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from vision_service.schemas import BBox, VehicleResult
|
||||||
|
from vision_service.vehicle import (
|
||||||
|
COCO_VEHICLE_CLASSES,
|
||||||
|
Detection,
|
||||||
|
decode,
|
||||||
|
letterbox,
|
||||||
|
nms,
|
||||||
|
pick_vehicle,
|
||||||
|
vehicles_from_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
SIZE = 64 # tiny "model" input: grids 8x8 + 4x4 + 2x2 = 84 rows
|
||||||
|
ROWS = (SIZE // 8) ** 2 + (SIZE // 16) ** 2 + (SIZE // 32) ** 2
|
||||||
|
|
||||||
|
|
||||||
|
def raw_output(hits: list[tuple[int, int, int, float, float, float, float]]) -> np.ndarray:
|
||||||
|
"""Build a YOLOX-style raw tensor [ROWS, 85] with the given (row, coco_class, _, obj,
|
||||||
|
cls_score, log_w, log_h) hits; everything else is background."""
|
||||||
|
raw = np.zeros((ROWS, 85), dtype=np.float32)
|
||||||
|
raw[:, 2:4] = -10.0 # exp → ~0 size for background rows
|
||||||
|
for row, cls, _, obj, score, lw, lh in hits:
|
||||||
|
raw[row, 0:2] = 0.5 # centre of its grid cell
|
||||||
|
raw[row, 2] = lw
|
||||||
|
raw[row, 3] = lh
|
||||||
|
raw[row, 4] = obj
|
||||||
|
raw[row, 5 + cls] = score
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_maps_grid_offsets_and_log_sizes_to_pixels() -> None:
|
||||||
|
raw = raw_output([(0, 2, 0, 1.0, 1.0, np.log(2.0), np.log(3.0))])
|
||||||
|
dec = decode(raw, SIZE)
|
||||||
|
# Row 0 = stride-8 grid cell (0,0): centre (0.5+0)*8 = 4, size exp(log 2)*8 = 16 / 24.
|
||||||
|
assert dec[0, :4].tolist() == [4.0, 4.0, 16.0, 24.0]
|
||||||
|
# Last row = stride-32 cell (1,1): centre (0.5+1)*32 = 48.
|
||||||
|
raw2 = raw_output([(ROWS - 1, 7, 0, 1.0, 1.0, 0.0, 0.0)])
|
||||||
|
dec2 = decode(raw2, SIZE)
|
||||||
|
assert dec2[ROWS - 1, :4].tolist() == [48.0, 48.0, 32.0, 32.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_vehicles_only_above_floor_mapped_to_vocabulary_and_scaled_back() -> None:
|
||||||
|
raw = raw_output(
|
||||||
|
[
|
||||||
|
(0, 2, 0, 0.9, 0.9, np.log(2.0), np.log(2.0)), # car, score .81
|
||||||
|
(1, 0, 0, 0.99, 0.99, np.log(2.0), np.log(2.0)), # person → ignored
|
||||||
|
(2, 7, 0, 0.5, 0.5, np.log(2.0), np.log(2.0)), # truck, score .25 → below floor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
found = vehicles_from_output(raw, SIZE, scale=0.5, min_confidence=0.4)
|
||||||
|
assert [d.body_type for d in found] == ["car"]
|
||||||
|
assert round(found[0].confidence, 2) == 0.81
|
||||||
|
# Box 16px wide in the letterboxed input → 32px in the original (scale 0.5).
|
||||||
|
assert round(found[0].x2 - found[0].x1) == 32
|
||||||
|
assert set(COCO_VEHICLE_CLASSES.values()) == {"car", "motorcycle", "bus", "truck"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_nms_keeps_the_best_of_overlapping_boxes() -> None:
|
||||||
|
boxes = np.array([[0, 0, 10, 10], [1, 1, 11, 11], [50, 50, 60, 60]], dtype=np.float32)
|
||||||
|
scores = np.array([0.5, 0.9, 0.7], dtype=np.float32)
|
||||||
|
assert sorted(nms(boxes, scores, 0.45)) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_prefers_the_box_holding_the_plate_else_the_largest() -> None:
|
||||||
|
near = Detection("car", 0.9, 0, 0, 100, 100)
|
||||||
|
far = Detection("truck", 0.8, 200, 200, 400, 400) # larger
|
||||||
|
inside = Detection("car", 0.7, 10, 10, 60, 60) # tighter box also holding the plate
|
||||||
|
assert pick_vehicle([near, far], None) is far
|
||||||
|
assert pick_vehicle([near, far], BBox(x1=20, y1=20, x2=30, y2=30)) is near
|
||||||
|
assert pick_vehicle([near, far, inside], BBox(x1=20, y1=20, x2=30, y2=30)) is inside
|
||||||
|
assert pick_vehicle([near, far], BBox(x1=900, y1=900, x2=910, y2=910)) is far # plate outside every box
|
||||||
|
assert pick_vehicle([], None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_letterbox_keeps_aspect_and_pads_with_114() -> None:
|
||||||
|
pytest.importorskip("cv2") # letterbox resizes with OpenCV — only present with the alpr extra
|
||||||
|
frame = np.zeros((30, 60, 3), dtype=np.uint8)
|
||||||
|
tensor, scale = letterbox(frame, 64)
|
||||||
|
assert tensor.shape == (1, 3, 64, 64) and tensor.dtype == np.float32
|
||||||
|
assert abs(scale - 64 / 60) < 1e-9
|
||||||
|
assert tensor[0, 0, 63, 63] == 114.0 # padding
|
||||||
|
assert tensor[0, 0, 0, 0] == 0.0 # image
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDetector:
|
||||||
|
model_version = "fake-vehicle"
|
||||||
|
|
||||||
|
def __init__(self, result: VehicleResult | None) -> None:
|
||||||
|
self.result = result
|
||||||
|
self.calls: list[BBox | None] = []
|
||||||
|
|
||||||
|
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||||
|
self.calls.append(plate)
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
def test_composition_fills_vehicle_over_the_stub_and_survives_a_failing_stage() -> None:
|
||||||
|
from vision_service.recognizer import StubRecognizer, WithVehicle
|
||||||
|
from vision_service.settings import Settings
|
||||||
|
|
||||||
|
det = FakeDetector(VehicleResult(body_type="truck", confidence=0.77))
|
||||||
|
rec = WithVehicle(StubRecognizer(Settings()), det)
|
||||||
|
res = rec.analyze(b"jpeg-bytes")
|
||||||
|
assert res.plate is None
|
||||||
|
assert res.vehicle == VehicleResult(body_type="truck", confidence=0.77)
|
||||||
|
assert res.model_version == "stub-0+fake-vehicle"
|
||||||
|
assert det.calls == [None]
|
||||||
|
|
||||||
|
class Boom:
|
||||||
|
model_version = "boom"
|
||||||
|
|
||||||
|
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||||
|
raise RuntimeError("no model")
|
||||||
|
|
||||||
|
rec2 = WithVehicle(StubRecognizer(Settings()), Boom())
|
||||||
|
res2 = rec2.analyze(b"jpeg-bytes")
|
||||||
|
assert res2.vehicle is None
|
||||||
|
assert rec2.ready is True
|
||||||
|
assert "vehicle: RuntimeError: no model" in (rec2.error or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_reports_a_missing_model_file_and_keeps_serving() -> None:
|
||||||
|
from vision_service.app import app
|
||||||
|
|
||||||
|
os.environ["VISION_VEHICLE_MODEL_PATH"] = "/nonexistent/yolox.onnx"
|
||||||
|
try:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
health = client.get("/health").json()
|
||||||
|
assert health["ready"] is True # the plate stage (stub) is fine
|
||||||
|
assert "vehicle:" in (health["detail"] or "")
|
||||||
|
res = client.post("/analyze", content=b"x", headers={"content-type": "application/octet-stream"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["vehicle"] is None
|
||||||
|
finally:
|
||||||
|
os.environ.pop("VISION_VEHICLE_MODEL_PATH", None)
|
||||||
Generated
+3
@@ -760,6 +760,8 @@ alpr = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
@@ -779,6 +781,7 @@ provides-extras = ["alpr"]
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "mypy", specifier = ">=1.13" },
|
{ name = "mypy", specifier = ">=1.13" },
|
||||||
|
{ name = "numpy", specifier = ">=1.26" },
|
||||||
{ name = "pytest", specifier = ">=8.3" },
|
{ name = "pytest", specifier = ">=8.3" },
|
||||||
{ name = "ruff", specifier = ">=0.8" },
|
{ name = "ruff", specifier = ">=0.8" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import Protocol
|
|||||||
|
|
||||||
from .schemas import AnalyzeResponse, BBox, PlateResult
|
from .schemas import AnalyzeResponse, BBox, PlateResult
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
|
from .vehicle import VehicleDetector, YoloxVehicleDetector
|
||||||
|
|
||||||
|
|
||||||
class Recognizer(Protocol):
|
class Recognizer(Protocol):
|
||||||
@@ -165,10 +166,59 @@ class FastAlprRecognizer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WithVehicle:
|
||||||
|
"""Composition: any plate recognizer + the vehicle stage. Runs the plate stage first
|
||||||
|
(its box picks WHICH vehicle), then fills `vehicle`. A failing vehicle stage is
|
||||||
|
logged into `error` and yields null — it must never cost the plate read."""
|
||||||
|
|
||||||
|
def __init__(self, inner: Recognizer, detector: VehicleDetector) -> None:
|
||||||
|
self._inner = inner
|
||||||
|
self._detector = detector
|
||||||
|
self.vehicle_error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_version(self) -> str:
|
||||||
|
return f"{self._inner.model_version}+{self._detector.model_version}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return bool(self._inner.ready)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self) -> str | None:
|
||||||
|
inner = getattr(self._inner, "error", None)
|
||||||
|
det = getattr(self._detector, "error", None) or self.vehicle_error
|
||||||
|
parts = [p for p in (inner, f"vehicle: {det}" if det else None) if p]
|
||||||
|
return "; ".join(parts) if parts else None
|
||||||
|
|
||||||
|
def analyze(self, image_bytes: bytes) -> AnalyzeResponse:
|
||||||
|
started = time.perf_counter()
|
||||||
|
res = self._inner.analyze(image_bytes)
|
||||||
|
try:
|
||||||
|
vehicle = self._detector.detect(image_bytes, res.plate.bbox if res.plate else None)
|
||||||
|
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
|
||||||
|
self.vehicle_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
vehicle = None
|
||||||
|
took_ms = (time.perf_counter() - started) * 1000.0
|
||||||
|
return res.model_copy(
|
||||||
|
update={"vehicle": vehicle, "model_version": self.model_version, "took_ms": took_ms}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_recognizer(settings: Settings) -> Recognizer:
|
def build_recognizer(settings: Settings) -> Recognizer:
|
||||||
"""Factory: pick the recognizer from settings. Falls back to the stub if the real
|
"""Factory: pick the recognizer from settings. Falls back to the stub if the real
|
||||||
one can't load, so the service always comes up (with ready=False surfaced)."""
|
one can't load, so the service always comes up (with ready=False surfaced). The
|
||||||
|
vehicle stage wraps whichever recognizer runs when a model path is configured."""
|
||||||
|
rec: Recognizer
|
||||||
if settings.recognizer == "fast_alpr":
|
if settings.recognizer == "fast_alpr":
|
||||||
rec = FastAlprRecognizer(settings)
|
rec = FastAlprRecognizer(settings)
|
||||||
return rec
|
else:
|
||||||
return StubRecognizer(settings)
|
rec = StubRecognizer(settings)
|
||||||
|
if settings.vehicle_model_path:
|
||||||
|
detector = YoloxVehicleDetector(
|
||||||
|
settings.vehicle_model_path,
|
||||||
|
input_size=settings.vehicle_input_size,
|
||||||
|
min_confidence=settings.vehicle_min_confidence,
|
||||||
|
)
|
||||||
|
return WithVehicle(rec, detector)
|
||||||
|
return rec
|
||||||
|
|||||||
@@ -31,10 +31,20 @@ 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)
|
||||||
|
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
|
||||||
|
bbox: BBox | None = None
|
||||||
make: str | None = None
|
make: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ class Settings(BaseSettings):
|
|||||||
# Node side can fall back to the ticket path rather than trust it.
|
# Node side can fall back to the ticket path rather than trust it.
|
||||||
min_confidence: float = 0.5
|
min_confidence: float = 0.5
|
||||||
|
|
||||||
|
# Vehicle stage (phase A — venue-modules.md §Vehicle category from vision): a YOLOX
|
||||||
|
# ONNX graph (Apache-2.0) run beside the plate recognizer. Unset = stage off (the
|
||||||
|
# response's `vehicle` stays null). Bake the file into the image (models/), never a
|
||||||
|
# path an operator can write (vision-service-hardening.md).
|
||||||
|
vehicle_model_path: str | None = None
|
||||||
|
vehicle_input_size: int = 640
|
||||||
|
# Detection score floor for a vehicle box to count at all (the Node side applies the
|
||||||
|
# site's own, stricter threshold before it FLAGS anything).
|
||||||
|
vehicle_min_confidence: float = 0.4
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Vehicle stage: a COCO object detector beside the plate recognizer (Job 2, phase A).
|
||||||
|
|
||||||
|
Answers "what KIND of vehicle is in this entry frame?" for the Car Wash desk's category
|
||||||
|
suggestion (wiki/decisions/venue-modules.md §Vehicle category from vision). ADVISORY by
|
||||||
|
design: the Node server records it next to the plate, the desk pre-selects the site
|
||||||
|
category it maps to, the operator decides, a confident downgrade is flagged. Nothing is
|
||||||
|
ever gated on it, so a wrong or missing detection costs nothing but a suggestion.
|
||||||
|
|
||||||
|
Model: YOLOX (Megvii, Apache-2.0) as an ONNX graph on the ONNX Runtime the plate stage
|
||||||
|
already uses — the licence rule that keeps Ultralytics (AGPL) out. COCO's vehicle classes
|
||||||
|
are car / motorcycle / bus / truck: enough to tell a van or a truck from a car, NOT enough
|
||||||
|
for SUV vs sedan — that is phase B (a body-type classifier on the pilot's own frames).
|
||||||
|
The detector's vehicle box is also the crop phase B will classify.
|
||||||
|
|
||||||
|
Pure numpy/cv2 pre/post-processing, no torch: letterbox to the model's square input
|
||||||
|
(pad 114, no normalisation — YOLOX's exported graphs take raw 0–255 BGR), decode the
|
||||||
|
stride grids, class-agnostic NMS, map COCO ids to the shared vocabulary, pick ONE
|
||||||
|
vehicle: the one whose box holds the plate (when a plate was read), else the largest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .schemas import BBox, VehicleResult
|
||||||
|
|
||||||
|
# COCO-80 class index → the shared VEHICLE_CLASSES vocabulary (packages/shared).
|
||||||
|
COCO_VEHICLE_CLASSES: dict[int, str] = {2: "car", 3: "motorcycle", 5: "bus", 7: "truck"}
|
||||||
|
|
||||||
|
# YOLOX feature strides; grids are input/stride per level (8400 anchors at 640).
|
||||||
|
_STRIDES = (8, 16, 32)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Detection:
|
||||||
|
body_type: str
|
||||||
|
confidence: float
|
||||||
|
x1: float
|
||||||
|
y1: float
|
||||||
|
x2: float
|
||||||
|
y2: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def area(self) -> float:
|
||||||
|
return max(0.0, self.x2 - self.x1) * max(0.0, self.y2 - self.y1)
|
||||||
|
|
||||||
|
def contains(self, x: float, y: float) -> bool:
|
||||||
|
return self.x1 <= x <= self.x2 and self.y1 <= y <= self.y2
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleDetector(Protocol):
|
||||||
|
"""What the recognizer composition needs: frame bytes (+ the plate box) → a class."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_version(self) -> str: ...
|
||||||
|
|
||||||
|
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# Pre/post-processing (pure functions — unit-tested on synthetic tensors)
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def letterbox(frame: Any, size: int) -> tuple[Any, float]:
|
||||||
|
"""Resize keeping aspect, pad bottom/right with 114 to size×size. Returns the CHW
|
||||||
|
float32 tensor (batch dim added) and the scale to map boxes back."""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
r = min(size / h, size / w)
|
||||||
|
nh, nw = int(round(h * r)), int(round(w * r))
|
||||||
|
resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)
|
||||||
|
padded = np.full((size, size, 3), 114, dtype=np.uint8)
|
||||||
|
padded[:nh, :nw] = resized
|
||||||
|
tensor = padded.transpose(2, 0, 1)[None].astype(np.float32)
|
||||||
|
return np.ascontiguousarray(tensor), r
|
||||||
|
|
||||||
|
|
||||||
|
def decode(raw: Any, size: int) -> Any:
|
||||||
|
"""YOLOX raw output [N, 5+classes] (batch squeezed) → same shape with xywh decoded
|
||||||
|
into pixel units of the letterboxed input. Rows are ordered stride 8, 16, 32."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
out = raw.astype(np.float32).copy()
|
||||||
|
grids = []
|
||||||
|
strides = []
|
||||||
|
for s in _STRIDES:
|
||||||
|
n = size // s
|
||||||
|
ys, xs = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
|
||||||
|
grids.append(np.stack((xs, ys), axis=-1).reshape(-1, 2))
|
||||||
|
strides.append(np.full((n * n, 1), s, dtype=np.float32))
|
||||||
|
grid = np.concatenate(grids, axis=0).astype(np.float32)
|
||||||
|
stride = np.concatenate(strides, axis=0)
|
||||||
|
if out.shape[0] != grid.shape[0]:
|
||||||
|
raise ValueError(f"unexpected output rows {out.shape[0]} for input {size} (want {grid.shape[0]})")
|
||||||
|
out[:, :2] = (out[:, :2] + grid) * stride
|
||||||
|
out[:, 2:4] = np.exp(out[:, 2:4]) * stride
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def nms(boxes: Any, scores: Any, iou_threshold: float) -> list[int]:
|
||||||
|
"""Greedy class-agnostic non-max suppression over xyxy boxes; returns kept indices."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if len(boxes) == 0:
|
||||||
|
return []
|
||||||
|
order = scores.argsort()[::-1]
|
||||||
|
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
|
||||||
|
areas = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None)
|
||||||
|
keep: list[int] = []
|
||||||
|
while order.size > 0:
|
||||||
|
i = int(order[0])
|
||||||
|
keep.append(i)
|
||||||
|
if order.size == 1:
|
||||||
|
break
|
||||||
|
rest = order[1:]
|
||||||
|
xx1 = np.maximum(x1[i], x1[rest])
|
||||||
|
yy1 = np.maximum(y1[i], y1[rest])
|
||||||
|
xx2 = np.minimum(x2[i], x2[rest])
|
||||||
|
yy2 = np.minimum(y2[i], y2[rest])
|
||||||
|
inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None)
|
||||||
|
iou = inter / (areas[i] + areas[rest] - inter + 1e-9)
|
||||||
|
order = rest[iou <= iou_threshold]
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
|
def vehicles_from_output(
|
||||||
|
raw: Any, size: int, scale: float, min_confidence: float, iou_threshold: float = 0.45
|
||||||
|
) -> list[Detection]:
|
||||||
|
"""Full post-processing: decode → vehicle classes only → confidence floor → NMS →
|
||||||
|
boxes in ORIGINAL frame pixels."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
dec = decode(raw, size)
|
||||||
|
cls_scores = dec[:, 5:]
|
||||||
|
cls_idx = cls_scores.argmax(axis=1)
|
||||||
|
score = dec[:, 4] * cls_scores[np.arange(len(dec)), cls_idx]
|
||||||
|
wanted = np.isin(cls_idx, list(COCO_VEHICLE_CLASSES)) & (score >= min_confidence)
|
||||||
|
if not wanted.any():
|
||||||
|
return []
|
||||||
|
d = dec[wanted]
|
||||||
|
s = score[wanted]
|
||||||
|
c = cls_idx[wanted]
|
||||||
|
boxes = np.stack(
|
||||||
|
(d[:, 0] - d[:, 2] / 2, d[:, 1] - d[:, 3] / 2, d[:, 0] + d[:, 2] / 2, d[:, 1] + d[:, 3] / 2), axis=1
|
||||||
|
)
|
||||||
|
keep = nms(boxes, s, iou_threshold)
|
||||||
|
out: list[Detection] = []
|
||||||
|
for i in keep:
|
||||||
|
b = boxes[i] / scale
|
||||||
|
out.append(
|
||||||
|
Detection(
|
||||||
|
body_type=COCO_VEHICLE_CLASSES[int(c[i])],
|
||||||
|
confidence=float(s[i]),
|
||||||
|
x1=float(b[0]),
|
||||||
|
y1=float(b[1]),
|
||||||
|
x2=float(b[2]),
|
||||||
|
y2=float(b[3]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pick_vehicle(detections: list[Detection], plate: BBox | None) -> Detection | None:
|
||||||
|
"""ONE vehicle per frame: the box holding the plate's centre (the car that was read —
|
||||||
|
a lane frame can show the car behind too), else the largest box (nearest the camera)."""
|
||||||
|
if not detections:
|
||||||
|
return None
|
||||||
|
if plate is not None:
|
||||||
|
cx = (plate.x1 + plate.x2) / 2
|
||||||
|
cy = (plate.y1 + plate.y2) / 2
|
||||||
|
holders = [d for d in detections if d.contains(cx, cy)]
|
||||||
|
if holders:
|
||||||
|
return min(holders, key=lambda d: d.area) # the tightest box around the plate
|
||||||
|
return max(detections, key=lambda d: d.area)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
# The ONNX Runtime detector
|
||||||
|
# ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class YoloxVehicleDetector:
|
||||||
|
"""YOLOX ONNX on onnxruntime (CPU). Loads once; a load failure is surfaced through
|
||||||
|
`error` and the stage simply yields no vehicle (never breaks the plate path)."""
|
||||||
|
|
||||||
|
def __init__(self, model_path: str, input_size: int = 640, min_confidence: float = 0.4) -> None:
|
||||||
|
self._path = Path(model_path)
|
||||||
|
self._size = input_size
|
||||||
|
self._min_confidence = min_confidence
|
||||||
|
self._session = None
|
||||||
|
self._input_name = "images"
|
||||||
|
self._error: str | None = None
|
||||||
|
try:
|
||||||
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
opts = ort.SessionOptions()
|
||||||
|
opts.intra_op_num_threads = 2 # one frame per entry; leave cores to the lane
|
||||||
|
self._session = ort.InferenceSession(
|
||||||
|
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
|
||||||
|
)
|
||||||
|
self._input_name = self._session.get_inputs()[0].name
|
||||||
|
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
|
||||||
|
self._error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_version(self) -> str:
|
||||||
|
return f"yolox:{self._path.name}@{self._size}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return self._session is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self) -> str | None:
|
||||||
|
return self._error
|
||||||
|
|
||||||
|
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
|
||||||
|
if self._session is None:
|
||||||
|
return None
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if frame is None:
|
||||||
|
return None
|
||||||
|
tensor, scale = letterbox(frame, self._size)
|
||||||
|
raw = self._session.run(None, {self._input_name: tensor})[0][0]
|
||||||
|
found = vehicles_from_output(raw, self._size, scale, self._min_confidence)
|
||||||
|
best = pick_vehicle(found, plate)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
box = BBox(
|
||||||
|
x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2))
|
||||||
|
)
|
||||||
|
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
|
||||||
|
|
||||||
|
|
||||||
|
def time_detect(
|
||||||
|
detector: VehicleDetector, image_bytes: bytes, plate: BBox | None
|
||||||
|
) -> tuple[VehicleResult | None, float]:
|
||||||
|
"""detect() with wall time in ms (for logs/benchmarks)."""
|
||||||
|
started = time.perf_counter()
|
||||||
|
result = detector.detect(image_bytes, plate)
|
||||||
|
return result, (time.perf_counter() - started) * 1000.0
|
||||||
@@ -41,6 +41,14 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Route-context consumers (RootLayout's nav, route beforeLoad guards) only re-read
|
||||||
|
// the router context on navigation — NOT when this `user` state changes. So after
|
||||||
|
// any session refresh (login, profile edit, a venue-module flip in Setup → Site)
|
||||||
|
// re-validate the current matches once React has committed the new context.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) void router.invalidate();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
// Apply the signed-in user's preferred language + theme + font scale whenever they
|
||||||
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
// resolve/change (login, bootstrap, or a toggle). Albanian + dark + 100% are the defaults
|
||||||
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
// before auth resolves; on logout, fall back so the Login screen is consistent.
|
||||||
|
|||||||
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Module charges folded into the settlement (e.g. a car wash ordered
|
||||||
|
with "pay at booth") — one "+" line each; the Total below includes
|
||||||
|
them. See wiki/decisions/venue-modules.md. */}
|
||||||
|
{!isSubscription &&
|
||||||
|
(s.chargeLines ?? []).length > 0 &&
|
||||||
|
s.currency != null && (
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="text-term-muted">{t("booth.charges")}</div>
|
||||||
|
{(s.chargeLines ?? []).map((c, i) => (
|
||||||
|
<div key={i} className="flex justify-between text-term-text">
|
||||||
|
<span>{c.label}</span>
|
||||||
|
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||||
out-of-window window charge; then show that amount. For an overstay the
|
out-of-window window charge; then show that amount. For an overstay the
|
||||||
amount is the TOP-UP delta, not the whole stay. */}
|
amount is the TOP-UP delta, not the whole stay. */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
|
|||||||
import { ActiveSessions } from "./ActiveSessions.js";
|
import { ActiveSessions } from "./ActiveSessions.js";
|
||||||
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
|
||||||
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
import { EventDetailModal, EventRow } from "./ui/event-detail.js";
|
||||||
|
import { tillOfEvent } from "@parking/shared";
|
||||||
|
|
||||||
// The live operator booth view — the real-time heart of the console. Occupancy
|
// The live operator booth view — the real-time heart of the console. Occupancy
|
||||||
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
// gauge + a streaming entry/exit/payment ticker. Query owns the initial load and
|
||||||
@@ -246,7 +247,7 @@ export function BoothScreen() {
|
|||||||
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
const occQuery = useQuery({ queryKey: qk.occupancy, queryFn: fetchOccupancy });
|
||||||
const eventsQuery = useQuery({
|
const eventsQuery = useQuery({
|
||||||
queryKey: [...qk.events, shiftStart ?? "none"],
|
queryKey: [...qk.events, shiftStart ?? "none"],
|
||||||
queryFn: () => fetchEvents(100, shiftStart ?? undefined),
|
queryFn: () => fetchEvents(100, shiftStart ?? undefined, undefined, "booth"),
|
||||||
enabled: shiftOpen,
|
enabled: shiftOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -275,13 +276,15 @@ export function BoothScreen() {
|
|||||||
|
|
||||||
// Merge: live events first (newest), then the queried history, de-duped by id —
|
// Merge: live events first (newest), then the queried history, de-duped by id —
|
||||||
// then clip to the current shift window (the live store spans shifts; the feed
|
// then clip to the current shift window (the live store spans shifts; the feed
|
||||||
// must not show events from before this shift's start). No shift → no feed.
|
// must not show events from before this shift's start) and to the BOOTH till (the
|
||||||
|
// socket also pushes wash-desk events to anyone with carwash:read; they are the wash
|
||||||
|
// shift's activity, not this one's — tillOfEvent). No shift → no feed.
|
||||||
const seen = new Set(liveFeed.map((e) => e.id));
|
const seen = new Set(liveFeed.map((e) => e.id));
|
||||||
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
|
||||||
const merged = [...liveFeed, ...history].slice(0, 200);
|
const merged = [...liveFeed, ...history].slice(0, 200);
|
||||||
const scoped =
|
const scoped =
|
||||||
shiftOpen && shiftStart
|
shiftOpen && shiftStart
|
||||||
? merged.filter((e) => e.occurredAt >= shiftStart)
|
? merged.filter((e) => e.occurredAt >= shiftStart && tillOfEvent(e.type, e.payload) === "booth")
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
|
||||||
|
|||||||
@@ -7,24 +7,30 @@ import {
|
|||||||
fetchEvents,
|
fetchEvents,
|
||||||
fetchShift,
|
fetchShift,
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchShiftTills,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
recordDrawerMovement,
|
recordDrawerMovement,
|
||||||
reviewDrawerMovement,
|
reviewDrawerMovement,
|
||||||
type DrawerMovement,
|
type DrawerMovement,
|
||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
|
type SessionUser,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
|
type TillId,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
import { shiftKey } from "./lib/use-shift.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import { tillGuards, tillOf, type LedgerEvent } from "@parking/shared";
|
||||||
|
import { can } from "./api.js";
|
||||||
|
|
||||||
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
|
||||||
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
// answers "what's in the till and why": the CURRENT drawer balance with the open
|
||||||
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
|
||||||
// activity (every cash payment and voucher, live), the movement record/review flow
|
// activity (every cash payment and voucher, live), the movement record/review flow
|
||||||
// (unchanged), and the closed-shift drawer history. All figures come from the signed
|
// (unchanged), and the closed-shift drawer history. All figures come from the signed
|
||||||
// chain — the drawer is a single site-wide till that carries across shifts. See
|
// chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
|
||||||
// wiki/concepts/shift.md.
|
// shows one till at a time — a switch appears when the site has more than one — and
|
||||||
|
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
|
||||||
|
|
||||||
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
|
||||||
|
|
||||||
@@ -50,9 +56,17 @@ function StatusBadge({ status }: { status: MovementStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
|
export function DrawerManager({ user, canReview }: { user: SessionUser | null; canReview: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
// Which tills this role may READ (each desk's drawer is guarded by that desk's own
|
||||||
|
// permissions) — the first one is the default view.
|
||||||
|
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||||
|
const tills: TillId[] = status.data?.tills.map((x) => x.till) ?? [];
|
||||||
|
const [chosen, setChosen] = useState<TillId | null>(null);
|
||||||
|
const till = chosen && tills.includes(chosen) ? chosen : (tills[0] ?? "booth");
|
||||||
|
// Recording on a till needs that till's `cash` guard (booth drawer:create, wash carwash:cash).
|
||||||
|
const canCreate = can(user, tillGuards(till).cash);
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
// A voucher moves the open shift's added/removed figures too (the X-report).
|
// A voucher moves the open shift's added/removed figures too (the X-report).
|
||||||
@@ -61,17 +75,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||||
|
{/* Till switch — only when there is more than one drawer to look at. */}
|
||||||
|
{tills.length > 1 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{tills.map((x) => (
|
||||||
|
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setChosen(x)}>
|
||||||
|
{t(`till.${x}Long`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Row 1: the till NOW + the record form. */}
|
{/* Row 1: the till NOW + the record form. */}
|
||||||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||||
<StatePanel />
|
<StatePanel till={till} />
|
||||||
{canCreate && <RecordPanel onDone={refresh} />}
|
{canCreate && <RecordPanel till={till} onDone={refresh} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||||
<TodayPanel />
|
<TodayPanel till={till} />
|
||||||
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
|
||||||
<ShiftHistoryPanel />
|
<ShiftHistoryPanel till={till} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -81,13 +106,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
|||||||
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
||||||
// number is always explainable: float + cash takings + in − out = expected = balance.
|
// number is always explainable: float + cash takings + in − out = expected = balance.
|
||||||
|
|
||||||
function StatePanel() {
|
function StatePanel({ till }: { till: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 });
|
const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
|
||||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||||||
const report = useQuery({
|
const report = useQuery({
|
||||||
queryKey: ["shift", "xreport"],
|
queryKey: ["shift", "xreport", till],
|
||||||
queryFn: fetchShiftReport,
|
queryFn: () => fetchShiftReport(till),
|
||||||
enabled: status.data?.open != null,
|
enabled: status.data?.open != null,
|
||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
@@ -149,17 +174,20 @@ function StatePanel() {
|
|||||||
// Every drawer-touching event since local midnight: cash payments (the current
|
// Every drawer-touching event since local midnight: cash payments (the current
|
||||||
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
// shift's incomings, live) + vouchers. Card payments never enter the till.
|
||||||
|
|
||||||
function TodayPanel() {
|
function TodayPanel({ till }: { till: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["drawer", "today"],
|
queryKey: ["drawer", "today", till],
|
||||||
queryFn: () => fetchEvents(1000, startOfToday()),
|
queryFn: () => fetchEvents(1000, startOfToday(), undefined, till),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This till's drawer-touching events only (a bay payment is wash-till money; a
|
||||||
|
// parking payment is booth money — tillOf() is the one shared rule).
|
||||||
const rows = (q.data?.events ?? []).filter((e) => {
|
const rows = (q.data?.events ?? []).filter((e) => {
|
||||||
|
if (tillOf(e.payload) !== till) return false;
|
||||||
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
if (e.type === "cash_in" || e.type === "cash_out") return true;
|
||||||
if (e.type !== "payment") return false;
|
if (e.type !== "payment" && e.type !== "carwash_payment") return false;
|
||||||
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
return (e.payload as { tender?: string } | null)?.tender !== "card";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,7 +199,7 @@ function TodayPanel() {
|
|||||||
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
|
||||||
const amt = pl.amountMinor ?? 0;
|
const amt = pl.amountMinor ?? 0;
|
||||||
if (pl.currency) cur = pl.currency;
|
if (pl.currency) cur = pl.currency;
|
||||||
if (e.type === "payment") {
|
if (e.type === "payment" || e.type === "carwash_payment") {
|
||||||
cashIn += amt;
|
cashIn += amt;
|
||||||
payments++;
|
payments++;
|
||||||
} else {
|
} else {
|
||||||
@@ -225,7 +253,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
|||||||
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||||
const time = formatClock(e.occurredAt);
|
const time = formatClock(e.occurredAt);
|
||||||
const label =
|
const label =
|
||||||
e.type === "payment"
|
e.type === "payment" || e.type === "carwash_payment"
|
||||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||||
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
|
||||||
return (
|
return (
|
||||||
@@ -243,13 +271,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
|||||||
|
|
||||||
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
// --- Movements (record + review) — the pre-redesign feature, unchanged ------
|
||||||
|
|
||||||
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) {
|
function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
// Reviewers can filter the list (the pending queue); operators always see their own, all.
|
||||||
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["drawer", "movements", canReview ? statusFilter : ""],
|
queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
|
||||||
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined),
|
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
|
||||||
});
|
});
|
||||||
const movements = q.data?.movements ?? [];
|
const movements = q.data?.movements ?? [];
|
||||||
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
const pendingCount = movements.filter((m) => m.status === "pending").length;
|
||||||
@@ -316,9 +344,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
|
|||||||
// --- Closed shifts, drawer-focused -------------------------------------------
|
// --- Closed shifts, drawer-focused -------------------------------------------
|
||||||
// Scope follows /api/shifts: operators see their own, admins all.
|
// Scope follows /api/shifts: operators see their own, admins all.
|
||||||
|
|
||||||
function ShiftHistoryPanel() {
|
function ShiftHistoryPanel({ till }: { till: TillId }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() });
|
const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
|
||||||
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
const shifts = (q.data?.shifts ?? []).slice(0, 50);
|
||||||
const showOperator = q.data?.scope === "all";
|
const showOperator = q.data?.scope === "all";
|
||||||
|
|
||||||
@@ -377,14 +405,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
|
|||||||
|
|
||||||
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
// --- Record form (unchanged from the pre-redesign feature) ------------------
|
||||||
|
|
||||||
function RecordPanel({ onDone }: { onDone: () => void }) {
|
function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
const record = useMutation({
|
const record = useMutation({
|
||||||
mutationFn: (type: "cash_in" | "cash_out") =>
|
mutationFn: (type: "cash_in" | "cash_out") =>
|
||||||
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }),
|
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
|
||||||
setAmount("");
|
setAmount("");
|
||||||
|
|||||||
@@ -13,12 +13,25 @@ import {
|
|||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { MODULES, jobsBehind, tillsFor, type JobPreset, type ModuleId, type TillId } from "@parking/shared";
|
||||||
|
|
||||||
// Role management (admin). Compose a role from the permission grid (a checkbox
|
// Role management (admin). Compose a role from the permission grid (a checkbox
|
||||||
// matrix of resource × action) and name it; users are then assigned a role. The
|
// matrix of resource × action) and name it; users are then assigned a role. The
|
||||||
// built-in `admin` role is shown read-only/locked (it always has every permission
|
// built-in `admin` role is shown read-only/locked (it always has every permission
|
||||||
// and can't be edited or deleted). The server enforces the same. See
|
// and can't be edited or deleted). The server enforces the same. See
|
||||||
// @parking/shared PERMISSIONS.
|
// @parking/shared PERMISSIONS.
|
||||||
|
//
|
||||||
|
// JOBS (venue-modules.md §"Permissions matrix", move 2): each EFFECTIVE module brings
|
||||||
|
// named permission bundles ("Booth operator", "Wash operator", "Merchant") offered as
|
||||||
|
// one-click chips above the grid — a chip adds/removes its bundle; the grid stays the
|
||||||
|
// fine-tune + enforcement layer. The editor LINTS the result (warnings, never blocks):
|
||||||
|
// "mixes desks" (may open more than one till) and "partial job" (holds a module's read
|
||||||
|
// permission but not the rest of its job — a desk that can look but not act).
|
||||||
|
//
|
||||||
|
// A role REMEMBERS the jobs it follows (chips on at save, or bundles fully present). When
|
||||||
|
// a later release grows a job, the role shows as "behind" it — in the list (with a
|
||||||
|
// one-click re-apply) and in the editor — instead of silently falling short the way the
|
||||||
|
// wash operator's price list did (2026-09-06). Every save is signed on the ledger.
|
||||||
|
|
||||||
/** Group "resource:action" permissions by resource for the grid rows. */
|
/** Group "resource:action" permissions by resource for the grid rows. */
|
||||||
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
function groupByResource(perms: Permission[]): Record<string, Permission[]> {
|
||||||
@@ -75,6 +88,7 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
<RoleEditor
|
<RoleEditor
|
||||||
role={editing === "new" ? null : editing}
|
role={editing === "new" ? null : editing}
|
||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
|
effective={(user?.modules ?? []) as ModuleId[]}
|
||||||
onCancel={() => setEditing(null)}
|
onCancel={() => setEditing(null)}
|
||||||
onSubmit={async (v) => {
|
onSubmit={async (v) => {
|
||||||
try {
|
try {
|
||||||
@@ -102,8 +116,17 @@ export function RolesManager({ user }: { user: SessionUser | null }) {
|
|||||||
<span className="text-[0.6875rem] text-term-muted">
|
<span className="text-[0.6875rem] text-term-muted">
|
||||||
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
{t("roles.permCount", { count: r.permissions.length })} · {t("roles.userCount", { count: r.userCount })}
|
||||||
</span>
|
</span>
|
||||||
|
{behindOf(r).map((b) => (
|
||||||
|
<span key={b.job} className="rounded-term border border-term-amber/60 px-1.5 py-0.5 text-[0.625rem] text-term-amber" title={b.missing.join(", ")}>
|
||||||
|
{t("roles.behind", { job: t(`jobs.${b.job}`) })}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{canUpdate && !r.builtin && behindOf(r).length > 0 && (
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" title={behindOf(r).flatMap((b) => b.missing).join(", ")}
|
||||||
|
onClick={() => reapply(r, invalidate, onError)}>{t("roles.reapply")}</button>
|
||||||
|
)}
|
||||||
{canUpdate && !r.builtin && (
|
{canUpdate && !r.builtin && (
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { setEditing(r); setError(null); }}>{t("roles.edit")}</button>
|
||||||
)}
|
)}
|
||||||
@@ -124,23 +147,86 @@ async function deleteRoleSafe(id: string, ok: () => void, onError: (e: unknown)
|
|||||||
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
try { await deleteRole(id); ok(); } catch (e) { onError(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The jobs a role follows that have grown past it (this release's bundles). */
|
||||||
|
function behindOf(r: ManagedRole): { job: string; missing: Permission[] }[] {
|
||||||
|
const has = new Set(r.permissions);
|
||||||
|
return jobsBehind(r.jobs ?? [], (p) => has.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-apply = add what the followed jobs now carry. Nothing is removed; the save is
|
||||||
|
* signed like any other role edit. */
|
||||||
|
async function reapply(r: ManagedRole, ok: () => void, onError: (e: unknown) => void) {
|
||||||
|
const missing = behindOf(r).flatMap((b) => b.missing);
|
||||||
|
try { await updateRole(r.id, { permissions: [...new Set([...r.permissions, ...missing])] }); ok(); } catch (e) { onError(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The jobs the composer offers: every effective module's, in registry order. */
|
||||||
|
function jobsFor(effective: readonly ModuleId[]): { module: ModuleId; job: JobPreset }[] {
|
||||||
|
return MODULES.filter((m) => effective.includes(m.id)).flatMap((m) => m.jobs.map((job) => ({ module: m.id, job })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Composer lints — warnings about what the admin just composed. */
|
||||||
|
function lintRole(perms: Set<Permission>, jobs: Set<string>, effective: readonly ModuleId[]): { key: string; vars?: Record<string, string> }[] {
|
||||||
|
const has = (p: Permission) => perms.has(p);
|
||||||
|
const out: { key: string; vars?: Record<string, string> }[] = [];
|
||||||
|
// Behind a job it follows: the bundle grew (a newer release) past what the role holds.
|
||||||
|
for (const b of jobsBehind([...jobs], has)) out.push({ key: "roles.lintJobBehind", vars: { job: b.job, missing: b.missing.join(", ") } });
|
||||||
|
// Mixes desks: may OPEN more than one till.
|
||||||
|
const workable: TillId[] = tillsFor(effective, has, "shift");
|
||||||
|
if (workable.length > 1) out.push({ key: "roles.lintMixedTills", vars: { tills: workable.join(", ") } });
|
||||||
|
// Partial job: holds a module's till-read (or a job's first permission) but not the
|
||||||
|
// rest of that job's OWN-resource permissions (a booth job also carries core
|
||||||
|
// permissions a supervisor legitimately leaves out — those don't count).
|
||||||
|
for (const { module, job } of jobsFor(effective)) {
|
||||||
|
const m = MODULES.find((x) => x.id === module)!;
|
||||||
|
const anchor = m.tillGuards?.read ?? job.permissions[0];
|
||||||
|
if (!anchor || !has(anchor)) continue;
|
||||||
|
const own = job.permissions.filter((p) => !has(p) && m.resources.some((r) => p.startsWith(`${r}:`)));
|
||||||
|
if (own.length > 0) out.push({ key: "roles.lintPartialJob", vars: { job: job.id, missing: own.join(", ") } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function RoleEditor({
|
function RoleEditor({
|
||||||
role, grouped, onCancel, onSubmit,
|
role, grouped, effective, onCancel, onSubmit,
|
||||||
}: {
|
}: {
|
||||||
role: ManagedRole | null;
|
role: ManagedRole | null;
|
||||||
grouped: Record<string, Permission[]>;
|
grouped: Record<string, Permission[]>;
|
||||||
|
effective: readonly ModuleId[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (v: { name: string; permissions: Permission[] }) => void;
|
onSubmit: (v: { name: string; permissions: Permission[]; jobs: string[] }) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(role?.name ?? "");
|
const [name, setName] = useState(role?.name ?? "");
|
||||||
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
|
const [perms, setPerms] = useState<Set<Permission>>(new Set(role?.permissions ?? []));
|
||||||
|
// The jobs this role follows: what was remembered, plus (at save) any bundle that is
|
||||||
|
// fully present — so a role composed before jobs were remembered picks them up.
|
||||||
|
const [jobIds, setJobIds] = useState<Set<string>>(new Set(role?.jobs ?? []));
|
||||||
const toggle = (p: Permission) =>
|
const toggle = (p: Permission) =>
|
||||||
setPerms((prev) => {
|
setPerms((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.has(p) ? next.delete(p) : next.add(p);
|
next.has(p) ? next.delete(p) : next.add(p);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
const jobs = useMemo(() => jobsFor(effective), [effective]);
|
||||||
|
const complete = (job: JobPreset) => job.permissions.every((p) => perms.has(p));
|
||||||
|
const jobOn = (job: JobPreset) => jobIds.has(job.id) || complete(job);
|
||||||
|
const toggleJob = (job: JobPreset) => {
|
||||||
|
const on = jobOn(job);
|
||||||
|
setJobIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
on ? next.delete(job.id) : next.add(job.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPerms((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) for (const p of job.permissions) next.delete(p);
|
||||||
|
else for (const p of job.permissions) next.add(p);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const lints = useMemo(() => lintRole(perms, jobIds, effective), [perms, jobIds, effective]);
|
||||||
|
const followed = () => jobs.filter(({ job }) => jobIds.has(job.id) || complete(job)).map(({ job }) => job.id);
|
||||||
|
|
||||||
const valid = name.trim().length > 0;
|
const valid = name.trim().length > 0;
|
||||||
|
|
||||||
@@ -151,6 +237,34 @@ function RoleEditor({
|
|||||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="label">{t("roles.jobs")}</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{jobs.map(({ module, job }) => (
|
||||||
|
<button
|
||||||
|
key={job.id}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${jobOn(job) ? "btn-primary" : ""}`}
|
||||||
|
title={job.permissions.join(", ")}
|
||||||
|
onClick={() => toggleJob(job)}
|
||||||
|
>
|
||||||
|
{t(`jobs.${job.id}`)} <span className="opacity-60">· {t(`modules.name.${module}`)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t("roles.jobsHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lints.length > 0 && (
|
||||||
|
<div className="mb-3 rounded-term border border-term-amber/60 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
|
{lints.map((l) => (
|
||||||
|
<div key={l.key + JSON.stringify(l.vars)}>{t(l.key, l.vars)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="label">{t("roles.permissions")}</div>
|
<div className="label">{t("roles.permissions")}</div>
|
||||||
<div className="mt-1 grid grid-cols-1 gap-1">
|
<div className="mt-1 grid grid-cols-1 gap-1">
|
||||||
{Object.entries(grouped).map(([resource, list]) => (
|
{Object.entries(grouped).map(([resource, list]) => (
|
||||||
@@ -171,7 +285,7 @@ function RoleEditor({
|
|||||||
|
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
<button type="button" className="btn btn-sm" onClick={onCancel}>{t("common.cancel")}</button>
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms] })}>{t("common.save")}</button>
|
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={() => onSubmit({ name: name.trim(), permissions: [...perms], jobs: followed() })}>{t("common.save")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
|
||||||
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
|
import { qk } from "./lib/query.js";
|
||||||
|
import { useShift } from "./lib/use-shift.js";
|
||||||
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift control for ONE TILL — the till's single-open shift expressed as one button:
|
||||||
|
* - no shift open → "Open shift" (enabled; opens this operator's shift on the till)
|
||||||
|
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||||
|
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||||
|
* open yours nor close theirs until they hand over).
|
||||||
|
* The header renders it for the booth; the wash desk renders it for the carwash till
|
||||||
|
* (its labels then name the till, so the two are never confused). On open/close it
|
||||||
|
* invalidates the shift status, the per-shift log, and occupancy.
|
||||||
|
* See wiki/concepts/shift.md "Tills".
|
||||||
|
*/
|
||||||
|
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { status, isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
||||||
|
// The till's `shift` guard (booth shift:create / wash carwash:cash). A role that may
|
||||||
|
// only LOOK sees the state text, never the button; the server refuses the same.
|
||||||
|
const canWork = status?.canWork ?? false;
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
// Closing a shift signs the Z-report and is irreversible, so the button never
|
||||||
|
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
||||||
|
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
||||||
|
const [confirmingClose, setConfirmingClose] = useState(false);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (isMine) {
|
||||||
|
setConfirmingClose(true);
|
||||||
|
} else {
|
||||||
|
void act("open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(kind: "open" | "close") {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
if (kind === "open") await openShift(till);
|
||||||
|
else await closeShift(till);
|
||||||
|
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["shifts"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The booth keeps its historical wording; any other till names itself.
|
||||||
|
const tillName = t(`till.${till}`);
|
||||||
|
const label = blockedByOther
|
||||||
|
? till === "booth"
|
||||||
|
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||||
|
: t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })
|
||||||
|
: isMine
|
||||||
|
? till === "booth"
|
||||||
|
? t("shift.headerClose")
|
||||||
|
: t("shift.tillClose", { till: tillName })
|
||||||
|
: till === "booth"
|
||||||
|
? t("shift.headerOpen")
|
||||||
|
: t("shift.tillOpen", { till: tillName });
|
||||||
|
const tone = blockedByOther
|
||||||
|
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||||
|
: isMine
|
||||||
|
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||||
|
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canWork && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || blockedByOther}
|
||||||
|
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!canWork && isOpen && (
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{till === "booth" ? t("shift.headerHeldByShort", { operator: heldBy ?? "?" }) : t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!isOpen && (
|
||||||
|
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||||
|
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||||
|
{confirmingClose && (
|
||||||
|
<CloseShiftConfirm
|
||||||
|
till={till}
|
||||||
|
busy={busy}
|
||||||
|
onCancel={() => setConfirmingClose(false)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await act("close");
|
||||||
|
setConfirmingClose(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm-before-close modal for the shift button. Fetches the till's live X-report so
|
||||||
|
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
||||||
|
* expected drawer before committing the irreversible Z-report. */
|
||||||
|
function CloseShiftConfirm({
|
||||||
|
till,
|
||||||
|
busy,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
till: TillId;
|
||||||
|
busy: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm", till], queryFn: () => fetchShiftReport(till) });
|
||||||
|
const x = q.data;
|
||||||
|
const cur = x?.currency ?? null;
|
||||||
|
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onCancel} title={till === "booth" ? t("shift.endShift") : t("shift.tillClose", { till: t(`till.${till}`) })} width="max-w-md">
|
||||||
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
|
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||||
|
{!x ? (
|
||||||
|
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
|
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
|
<span />
|
||||||
|
{/* Split by source — only meaningful on the booth (a wash till has no
|
||||||
|
tickets or subscriptions; its takings are the bay payments). */}
|
||||||
|
{till === "booth" && (
|
||||||
|
<>
|
||||||
|
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||||
|
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||||
|
{/* Module money that rode the ticket (a booth-paid wash) — only when any did. */}
|
||||||
|
{Object.entries(x.chargesByModuleMinor ?? {})
|
||||||
|
.filter(([, v]) => (v ?? 0) > 0)
|
||||||
|
.map(([m, v]) => (
|
||||||
|
<Fragment key={m}>
|
||||||
|
<ConfirmFigure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={fmt(v ?? 0)} />
|
||||||
|
<span />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||||
|
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||||
|
<span />
|
||||||
|
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||||
|
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||||
|
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||||
|
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||||
|
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||||
|
<span />
|
||||||
|
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||||
|
{busy ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Spinner /> {t("shift.ending")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("shift.endShift")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||||
|
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+114
-40
@@ -1,16 +1,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
closeShift,
|
closeShift,
|
||||||
fetchEvents,
|
fetchEvents,
|
||||||
fetchShift,
|
|
||||||
fetchShiftReport,
|
fetchShiftReport,
|
||||||
|
fetchShiftTills,
|
||||||
fetchShifts,
|
fetchShifts,
|
||||||
openShift,
|
openShift,
|
||||||
type ShiftReport,
|
type ShiftReport,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
|
type TillId,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
@@ -24,7 +25,9 @@ import type { LedgerEvent } from "@parking/shared";
|
|||||||
// selected shift's signed activity log (every ledger event in its window). The current
|
// selected shift's signed activity log (every ledger event in its window). The current
|
||||||
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
|
||||||
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
|
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
|
||||||
// an admin (shift:cash) sees all. See wiki/concepts/shift.md.
|
// an admin (shift:cash) sees all. TILLS: a shift belongs to a till (booth / wash desk);
|
||||||
|
// every open shift (one per till) lists on top, cards carry a till badge when the site
|
||||||
|
// has more than one, and the list can be filtered by till. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
function money(minor: number, currency: string | null): string {
|
function money(minor: number, currency: string | null): string {
|
||||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||||
@@ -47,28 +50,36 @@ function presetRange(p: Preset): { from: string; to: string } | null {
|
|||||||
return { from: iso(from), to: iso(now) };
|
return { from: iso(from), to: iso(now) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed
|
type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
|
||||||
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
|
|
||||||
* no shift is open (or not visible to the requester). */
|
/** The CURRENT (open) shifts — one per till at most — each synthesized from its till's
|
||||||
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } {
|
* X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
|
||||||
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift });
|
* marks it for the badge + the action pane. Also returns every till the site has, so
|
||||||
const report = useQuery({
|
* the hub can offer "start shift" per till and show badges only when there are two. */
|
||||||
queryKey: ["shift", "xreport"],
|
function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; workable: TillId[]; refetch: () => void } {
|
||||||
queryFn: fetchShiftReport,
|
const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
|
||||||
enabled: status.data?.open != null,
|
const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
|
||||||
|
// One X-report per open till (the key carries the till list so a newly opened
|
||||||
|
// shift refetches).
|
||||||
|
const reports = useQuery({
|
||||||
|
queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")],
|
||||||
|
queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))),
|
||||||
|
enabled: openTills.length > 0,
|
||||||
});
|
});
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
void status.refetch();
|
void status.refetch();
|
||||||
void report.refetch();
|
void reports.refetch();
|
||||||
};
|
};
|
||||||
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch };
|
const tills = status.data?.tills.map((t) => t.till) ?? [];
|
||||||
const x = report.data;
|
const workable = status.data?.tills.filter((t) => t.canWork).map((t) => t.till) ?? [];
|
||||||
return {
|
const current: CurrentShift[] = [];
|
||||||
isMine: status.data.isMine,
|
openTills.forEach((t, i) => {
|
||||||
refetch,
|
const x = reports.data?.[i];
|
||||||
current: {
|
if (!x) return;
|
||||||
id: "__current__",
|
current.push({
|
||||||
|
id: `__current__${t.till}`,
|
||||||
index: Number.MAX_SAFE_INTEGER,
|
index: Number.MAX_SAFE_INTEGER,
|
||||||
|
till: x.till,
|
||||||
operator: x.operator,
|
operator: x.operator,
|
||||||
startedAt: x.startedAt,
|
startedAt: x.startedAt,
|
||||||
endedAt: x.asOf,
|
endedAt: x.asOf,
|
||||||
@@ -80,13 +91,16 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
|
|||||||
subscriptionTotalMinor: x.subscriptionTotalMinor,
|
subscriptionTotalMinor: x.subscriptionTotalMinor,
|
||||||
subscriptionSalesMinor: x.subscriptionSalesMinor,
|
subscriptionSalesMinor: x.subscriptionSalesMinor,
|
||||||
subscriptionWindowMinor: x.subscriptionWindowMinor,
|
subscriptionWindowMinor: x.subscriptionWindowMinor,
|
||||||
|
chargesByModuleMinor: x.chargesByModuleMinor,
|
||||||
openingFloatMinor: x.openingFloatMinor,
|
openingFloatMinor: x.openingFloatMinor,
|
||||||
cashAddedMinor: x.cashAddedMinor,
|
cashAddedMinor: x.cashAddedMinor,
|
||||||
cashRemovedMinor: x.cashRemovedMinor,
|
cashRemovedMinor: x.cashRemovedMinor,
|
||||||
expectedDrawerMinor: x.expectedDrawerMinor,
|
expectedDrawerMinor: x.expectedDrawerMinor,
|
||||||
open: true,
|
open: true,
|
||||||
},
|
isMine: t.isMine,
|
||||||
};
|
});
|
||||||
|
});
|
||||||
|
return { current, tills, workable, refetch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
|
||||||
@@ -96,14 +110,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
const [customFrom, setCustomFrom] = useState("");
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
const [customTo, setCustomTo] = useState("");
|
const [customTo, setCustomTo] = useState("");
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [tillFilter, setTillFilter] = useState<TillId | "">("");
|
||||||
|
|
||||||
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
|
const { current, tills, workable, refetch: refetchCurrent } = useCurrentShifts();
|
||||||
|
const multiTill = tills.length > 1;
|
||||||
|
|
||||||
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
||||||
const applied = {
|
const applied = {
|
||||||
operator: operator.trim() || undefined,
|
operator: operator.trim() || undefined,
|
||||||
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
|
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
|
||||||
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
|
||||||
|
till: tillFilter || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// keepPreviousData: every filter change makes a NEW query key; without it the
|
// keepPreviousData: every filter change makes a NEW query key; without it the
|
||||||
@@ -118,16 +135,23 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
const closed = q.data?.shifts ?? [];
|
const closed = q.data?.shifts ?? [];
|
||||||
const operators = q.data?.operators ?? [];
|
const operators = q.data?.operators ?? [];
|
||||||
|
|
||||||
// The current/open shift sits at the TOP of the list (when present + visible to me).
|
// The current/open shifts sit at the TOP of the list (those visible to me: mine, or
|
||||||
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
|
// all for an admin), honouring the till filter.
|
||||||
|
const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter));
|
||||||
|
const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed];
|
||||||
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
|
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
|
||||||
|
const currentIds = visibleCurrent.map((c) => c.id).join(",");
|
||||||
|
|
||||||
// Default the selection to the current shift (if any), else the newest closed one.
|
// Default the selection to the current shift (if any), else the newest closed one.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (list.length === 0) setSelectedId(null);
|
if (list.length === 0) setSelectedId(null);
|
||||||
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [q.data, current?.id]);
|
}, [q.data, currentIds]);
|
||||||
|
|
||||||
|
// Tills this role may WORK with no open shift → offer "start" for each.
|
||||||
|
const openOn = new Set(current.map((c) => c.till));
|
||||||
|
const startable = workable.filter((x) => !openOn.has(x));
|
||||||
|
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
void q.refetch();
|
void q.refetch();
|
||||||
@@ -145,9 +169,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||||
</h1>
|
</h1>
|
||||||
{/* No shift open → the only action is to start one (gated on shift:create). */}
|
{/* A till with no open shift → the action is to start one (gated on shift:create). */}
|
||||||
{canManage && !current && (
|
{canManage && startable.length > 0 && (
|
||||||
<StartShiftButton onDone={refreshAll} />
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
{startable.map((x) => (
|
||||||
|
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -175,6 +203,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{multiTill && (
|
||||||
|
<div className="field">
|
||||||
|
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
|
||||||
|
<option value="">{t("till.all")}</option>
|
||||||
|
{tills.map((x) => (
|
||||||
|
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
{/* <span className="label">{t("shifts.operator")}</span> */}
|
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||||
@@ -202,7 +240,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||||
)}
|
)}
|
||||||
{list.map((s) => (
|
{list.map((s) => (
|
||||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
<ShiftCard key={s.id} s={s} showOperator={isAdmin} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,8 +249,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
<ShiftActivityLog
|
<ShiftActivityLog
|
||||||
shift={selected}
|
shift={selected}
|
||||||
isCurrent={!!selected.open}
|
isCurrent={!!selected.open}
|
||||||
isMine={isMine}
|
isMine={!!selected.isMine}
|
||||||
showOperator={isAdmin}
|
showOperator={isAdmin}
|
||||||
|
showTill={multiTill}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
onChanged={refreshAll}
|
onChanged={refreshAll}
|
||||||
/>
|
/>
|
||||||
@@ -225,7 +264,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StartShiftButton({ onDone }: { onDone: () => void }) {
|
function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -233,7 +272,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
await openShift();
|
await openShift(till);
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
@@ -249,6 +288,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Spinner /> {t("shift.starting")}
|
<Spinner /> {t("shift.starting")}
|
||||||
</span>
|
</span>
|
||||||
|
) : named ? (
|
||||||
|
t("shift.tillOpen", { till: t(`till.${till}`) })
|
||||||
) : (
|
) : (
|
||||||
t("shift.startShift")
|
t("shift.startShift")
|
||||||
)}
|
)}
|
||||||
@@ -257,7 +298,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
/** Which drawer a shift reconciled — shown only when the site has more than one. */
|
||||||
|
function TillBadge({ till }: { till: TillId }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cur = s.currency;
|
const cur = s.currency;
|
||||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||||
@@ -270,6 +317,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||||
|
{showTill && <TillBadge till={s.till} />}
|
||||||
{showOperator ? s.operator : when(s.startedAt)}
|
{showOperator ? s.operator : when(s.startedAt)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||||
@@ -285,11 +333,30 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One figure per module whose money rode this till's tickets (a booth-paid wash) —
|
||||||
|
* nothing when none did, so booth-only sites see the report they always saw. `spacer`
|
||||||
|
* keeps a 2-column grid's pairs aligned. */
|
||||||
|
function ChargeFigures({ charges, cur, spacer }: { charges?: Partial<Record<string, number>>; cur: string | null; spacer?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const rows = Object.entries(charges ?? {}).filter(([, v]) => (v ?? 0) > 0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rows.map(([m, v]) => (
|
||||||
|
<Fragment key={m}>
|
||||||
|
<Figure label={t("shift.srcOnTicket", { module: t(`modules.name.${m}`) })} value={money(v ?? 0, cur)} />
|
||||||
|
{spacer && <span />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ShiftActivityLog({
|
function ShiftActivityLog({
|
||||||
shift,
|
shift,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
isMine,
|
isMine,
|
||||||
showOperator,
|
showOperator,
|
||||||
|
showTill,
|
||||||
canManage,
|
canManage,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
@@ -297,6 +364,7 @@ function ShiftActivityLog({
|
|||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
showOperator: boolean;
|
showOperator: boolean;
|
||||||
|
showTill: boolean;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -307,9 +375,10 @@ function ShiftActivityLog({
|
|||||||
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
|
||||||
|
|
||||||
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
// The current shift's log runs entry→now (no upper bound); a closed shift is bounded.
|
||||||
|
// Per till: the booth's log has no wash-desk activity in it, and vice versa.
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["shift-events", shift.id, shift.endedAt],
|
queryKey: ["shift-events", shift.id, shift.endedAt, shift.till],
|
||||||
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt),
|
queryFn: () => fetchEvents(1000, shift.startedAt, isCurrent ? undefined : shift.endedAt, shift.till),
|
||||||
refetchInterval: isCurrent ? 5000 : false,
|
refetchInterval: isCurrent ? 5000 : false,
|
||||||
});
|
});
|
||||||
const events = q.data?.events ?? [];
|
const events = q.data?.events ?? [];
|
||||||
@@ -322,6 +391,7 @@ function ShiftActivityLog({
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||||
|
{showTill && <TillBadge till={shift.till} />}
|
||||||
{showOperator && `${shift.operator} · `}
|
{showOperator && `${shift.operator} · `}
|
||||||
{formatRelativeDateTime(shift.startedAt, t)}
|
{formatRelativeDateTime(shift.startedAt, t)}
|
||||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||||
@@ -339,6 +409,7 @@ function ShiftActivityLog({
|
|||||||
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
<Figure label={t("shifts.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
<Figure label={t("shifts.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
|
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} />
|
||||||
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
<Figure label={t("shifts.openingFloat")} value={money(shift.openingFloatMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
<Figure label={t("shifts.cashTaken")} value={money(shift.cashTotalMinor, cur)} />
|
||||||
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
<Figure label={t("shifts.cashAdded")} value={money(shift.cashAddedMinor, cur)} />
|
||||||
@@ -358,7 +429,7 @@ function ShiftActivityLog({
|
|||||||
|
|
||||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
{modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,7 +447,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
setReport(await closeShift());
|
setReport(await closeShift(shift.till));
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
@@ -396,6 +467,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
<Figure label={t("shift.srcTickets")} value={money(report.ticketTotalMinor, report.currency)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(report.subscriptionTotalMinor, report.currency)} />
|
||||||
|
<ChargeFigures charges={report.chargesByModuleMinor} cur={report.currency} spacer />
|
||||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(report.subscriptionWindowMinor, report.currency)} sub />
|
||||||
@@ -422,6 +494,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
<Figure label={t("shift.srcTickets")} value={money(shift.ticketTotalMinor, cur)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(shift.subscriptionTotalMinor, cur)} />
|
||||||
|
<ChargeFigures charges={shift.chargesByModuleMinor} cur={cur} spacer />
|
||||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(shift.subscriptionWindowMinor, cur)} sub />
|
||||||
@@ -447,9 +520,9 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TakingsModal({ onClose }: { onClose: () => void }) {
|
function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport });
|
const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) });
|
||||||
const x = q.data;
|
const x = q.data;
|
||||||
return (
|
return (
|
||||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||||
@@ -463,6 +536,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
<Figure label={t("shift.srcTickets")} value={money(x.ticketTotalMinor, x.currency)} />
|
||||||
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
<Figure label={t("shift.srcSubscriptions")} value={money(x.subscriptionTotalMinor, x.currency)} />
|
||||||
|
<ChargeFigures charges={x.chargesByModuleMinor} cur={x.currency} spacer />
|
||||||
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
{/* Abonime is the subscription TOTAL; only the out-of-window part is broken out. */}
|
||||||
<span />
|
<span />
|
||||||
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
<Figure label={t("shift.srcSubWindow")} value={money(x.subscriptionWindowMinor, x.currency)} sub />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
|
fetchMe,
|
||||||
fetchOccupancy,
|
fetchOccupancy,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
fetchValidationPrograms,
|
fetchValidationPrograms,
|
||||||
@@ -10,7 +12,9 @@ import {
|
|||||||
type SiteConfig,
|
type SiteConfig,
|
||||||
type ValidationProgramView,
|
type ValidationProgramView,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
|
import { STATIONS, ValidationStationsPanel, defaultProgram, stationLabelKey, type StationId } from "./ValidationSetup.js";
|
||||||
|
import { MODULES, type ModuleId } from "@parking/shared";
|
||||||
|
import type { RouterContext } from "./router.js";
|
||||||
|
|
||||||
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
||||||
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
||||||
@@ -42,23 +46,43 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
// station's `active` (persisted at once — each flip signs a config_change); the
|
// station's `active` (persisted at once — each flip signs a config_change); the
|
||||||
// right-column panel edits the enabled stations. See validation-discounts.md.
|
// right-column panel edits the enabled stations. See validation-discounts.md.
|
||||||
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
|
||||||
|
// Venue modules: what this site is entitled to (vendor-set), what the admin has
|
||||||
|
// activated, and the effective set. Toggling persists at once (the server signs a
|
||||||
|
// config_change per module that flips and validates dependencies). See
|
||||||
|
// wiki/decisions/venue-modules.md.
|
||||||
|
const [mods, setMods] = useState<{ entitled: ModuleId[]; activated: ModuleId[]; effective: ModuleId[] } | null>(null);
|
||||||
|
const [modMsg, setModMsg] = useState<string | null>(null);
|
||||||
|
const moduleOn = (id: ModuleId) => mods?.effective.includes(id) ?? false;
|
||||||
|
// The header nav gates module entries on the SESSION's module set (/api/auth/me),
|
||||||
|
// so a flip here must refresh the session too or the nav stays stale until reload
|
||||||
|
// (App re-validates the router whenever `user` changes).
|
||||||
|
const { setUser } = useRouteContext({ strict: false }) as RouterContext;
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
fetchOccupancy().then(setOcc).catch(() => {});
|
fetchOccupancy().then(setOcc).catch(() => {});
|
||||||
}
|
}
|
||||||
|
/** The validation programs are a module route — only ask for them while the
|
||||||
|
* module is effective (the server 403s otherwise, which would land in app_logs
|
||||||
|
* as a failed request every time an admin opens this page). */
|
||||||
|
function loadPrograms(effective: ModuleId[]) {
|
||||||
|
if (!canEdit || !effective.includes("validation")) {
|
||||||
|
setPrograms([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => setPrograms(r.programs))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
if (canEdit) {
|
|
||||||
fetchValidationPrograms()
|
|
||||||
.then((r) => setPrograms(r.programs))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
fetchSiteConfig()
|
fetchSiteConfig()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
||||||
setExitVoucherDefault(c.exitVoucherDefault);
|
setExitVoucherDefault(c.exitVoucherDefault);
|
||||||
setReserveSubs(c.reserveSubscriberSpots);
|
setReserveSubs(c.reserveSubscriberSpots);
|
||||||
setAnprEntry(c.anprEntryEnabled);
|
setAnprEntry(c.anprEntryEnabled);
|
||||||
|
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
|
||||||
|
loadPrograms(c.modules);
|
||||||
const m: Record<string, string> = {};
|
const m: Record<string, string> = {};
|
||||||
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
||||||
setMeta(m);
|
setMeta(m);
|
||||||
@@ -73,7 +97,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
const existing = programs.find((p) => p.id === id);
|
const existing = programs.find((p) => p.id === id);
|
||||||
const body = existing
|
const body = existing
|
||||||
? { ...existing, active }
|
? { ...existing, active }
|
||||||
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
|
: { ...defaultProgram(id, t(stationLabelKey(id))), active };
|
||||||
try {
|
try {
|
||||||
const saved = await saveValidationProgram(id, body);
|
const saved = await saveValidationProgram(id, body);
|
||||||
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
|
||||||
@@ -82,6 +106,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Flip a module: send the full desired activation set; the server decides
|
||||||
|
* (required always on, must be entitled, dependencies) and echoes the result. */
|
||||||
|
async function toggleModule(id: ModuleId, on: boolean) {
|
||||||
|
if (!mods) return;
|
||||||
|
setModMsg(null);
|
||||||
|
const next = on ? [...new Set([...mods.activated, id])] : mods.activated.filter((m) => m !== id);
|
||||||
|
try {
|
||||||
|
const c = await saveSiteConfig({ modules: next });
|
||||||
|
setMods({ entitled: c.modulesEntitled, activated: c.modulesActivated, effective: c.modules });
|
||||||
|
loadPrograms(c.modules);
|
||||||
|
const me = await fetchMe();
|
||||||
|
if (me) setUser(me);
|
||||||
|
} catch (e) {
|
||||||
|
setModMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const raw = capInput.trim();
|
const raw = capInput.trim();
|
||||||
@@ -164,22 +205,53 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("val.sectionTitle")}
|
{t("modules.sectionTitle")}
|
||||||
</div>
|
</div>
|
||||||
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
<span className="hint -mt-2">{t("modules.sectionHint")}</span>
|
||||||
<div className="flex gap-6">
|
<div className="grid gap-1.5">
|
||||||
{STATIONS.map((id) => (
|
{MODULES.filter((m) => mods?.entitled.includes(m.id)).map((m) => (
|
||||||
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
<label key={m.id} className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-term-amber"
|
className="mt-0.5 accent-term-amber"
|
||||||
checked={programs.find((p) => p.id === id)?.active ?? false}
|
checked={moduleOn(m.id)}
|
||||||
onChange={(e) => toggleStation(id, e.target.checked)}
|
disabled={m.required || !mods}
|
||||||
|
onChange={(e) => toggleModule(m.id, e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
<span>
|
||||||
|
{t(`modules.name.${m.id}`)}
|
||||||
|
{m.required && <span className="hint block">{t("modules.required")}</span>}
|
||||||
|
{m.dependsOn.length > 0 && (
|
||||||
|
<span className="hint block">
|
||||||
|
{t("modules.requires", { deps: m.dependsOn.map((d) => t(`modules.name.${d}`)).join(", ") })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
{modMsg && <span className="text-[0.75rem] text-term-red">{modMsg}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{moduleOn("validation") && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("val.sectionTitle")}
|
||||||
|
</div>
|
||||||
|
<span className="hint -mt-2">{t("val.sectionHint")}</span>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{STATIONS.map((id) => (
|
||||||
|
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={programs.find((p) => p.id === id)?.active ?? false}
|
||||||
|
onChange={(e) => toggleStation(id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t(stationLabelKey(id))}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
{t("site.parkDetails")}
|
{t("site.parkDetails")}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +283,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
{canEdit && (
|
{canEdit && moduleOn("validation") && (
|
||||||
<ValidationStationsPanel
|
<ValidationStationsPanel
|
||||||
programs={programs}
|
programs={programs}
|
||||||
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
|
||||||
import {
|
import {
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
saveValidationProgram,
|
saveValidationProgram,
|
||||||
@@ -15,12 +16,22 @@ import {
|
|||||||
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
|
||||||
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
/** The two well-known stations the checkboxes toggle. */
|
/** The well-known merchant stations the checkboxes toggle. Was `["bar", "lavazh"]`;
|
||||||
export const STATIONS = ["bar", "lavazh"] as const;
|
* the Lavazh (car-wash) station was retired 2026-09-05 — the Car Wash module
|
||||||
|
* sponsors parking through its own order flow instead (wiki/decisions/
|
||||||
|
* venue-modules.md). Existing `lavazh` program rows are untouched data; the server
|
||||||
|
* accepts any kebab slug, so they simply no longer have a checkbox. */
|
||||||
|
export const STATIONS = ["bar"] as const;
|
||||||
export type StationId = (typeof STATIONS)[number];
|
export type StationId = (typeof STATIONS)[number];
|
||||||
|
|
||||||
|
/** i18n label for a station's checkbox / tab. */
|
||||||
|
const STATION_LABEL_KEY: Record<StationId, string> = { bar: "val.enableBar" };
|
||||||
|
export function stationLabelKey(id: StationId): string {
|
||||||
|
return STATION_LABEL_KEY[id];
|
||||||
|
}
|
||||||
|
|
||||||
/** A blank program draft for a station enabled for the first time. */
|
/** A blank program draft for a station enabled for the first time. */
|
||||||
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
|
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
|
||||||
return {
|
return {
|
||||||
name: label,
|
name: label,
|
||||||
mode: "comp",
|
mode: "comp",
|
||||||
@@ -46,13 +57,36 @@ const toInt = (s: string): number | null => {
|
|||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
};
|
};
|
||||||
|
/** Like toInt but 0 is valid (a tolerance of "not a minute more"). */
|
||||||
|
const toNonNeg = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||||
|
};
|
||||||
|
const MODE_LABEL_KEY: Record<ValidationMode, string> = {
|
||||||
|
comp: "val.modeComp",
|
||||||
|
timeCredit: "val.modeTimeCredit",
|
||||||
|
fixed: "val.modeFixed",
|
||||||
|
percent: "val.modePercent",
|
||||||
|
doneTolerance: "val.modeDoneTolerance",
|
||||||
|
washPrice: "val.modeWashPrice",
|
||||||
|
};
|
||||||
|
|
||||||
function StationForm({
|
/** One validation program's editor. Also reused by the Car Wash module for its
|
||||||
|
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
|
||||||
|
* bound merchant users). */
|
||||||
|
export function StationForm({
|
||||||
program,
|
program,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
hideUsers = false,
|
||||||
|
modes = MERCHANT_VALIDATION_MODES,
|
||||||
}: {
|
}: {
|
||||||
program: ValidationProgramView;
|
program: ValidationProgramView;
|
||||||
onSaved: (p: ValidationProgramView) => void;
|
onSaved: (p: ValidationProgramView) => void;
|
||||||
|
hideUsers?: boolean;
|
||||||
|
/** Which discount modes to offer (merchant stations vs the car wash differ). */
|
||||||
|
modes?: readonly ValidationMode[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useState(program.name);
|
const [name, setName] = useState(program.name);
|
||||||
@@ -86,6 +120,7 @@ function StationForm({
|
|||||||
const valid = useMemo(() => {
|
const valid = useMemo(() => {
|
||||||
if (!name.trim()) return false;
|
if (!name.trim()) return false;
|
||||||
if (mode === "timeCredit") return toInt(minutes) != null;
|
if (mode === "timeCredit") return toInt(minutes) != null;
|
||||||
|
if (mode === "doneTolerance") return toNonNeg(minutes) != null;
|
||||||
if (mode === "percent") {
|
if (mode === "percent") {
|
||||||
const p = toInt(percent);
|
const p = toInt(percent);
|
||||||
return p != null && p <= 100;
|
return p != null && p <= 100;
|
||||||
@@ -100,7 +135,7 @@ function StationForm({
|
|||||||
const saved = await saveValidationProgram(program.id, {
|
const saved = await saveValidationProgram(program.id, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
mode,
|
mode,
|
||||||
minutes: mode === "timeCredit" ? toInt(minutes) : null,
|
minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null,
|
||||||
percent: mode === "percent" ? toInt(percent) : null,
|
percent: mode === "percent" ? toInt(percent) : null,
|
||||||
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
|
||||||
maxPerDay: toInt(maxPerDay),
|
maxPerDay: toInt(maxPerDay),
|
||||||
@@ -130,11 +165,12 @@ function StationForm({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("val.mode")}</span>
|
<span className="label">{t("val.mode")}</span>
|
||||||
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||||
<option value="comp">{t("val.modeComp")}</option>
|
{modes.map((m) => (
|
||||||
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
<option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
|
||||||
<option value="fixed">{t("val.modeFixed")}</option>
|
))}
|
||||||
<option value="percent">{t("val.modePercent")}</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
|
||||||
|
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
|
||||||
</div>
|
</div>
|
||||||
{mode === "timeCredit" && (
|
{mode === "timeCredit" && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -142,6 +178,12 @@ function StationForm({
|
|||||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mode === "doneTolerance" && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("val.toleranceMinutes")}</span>
|
||||||
|
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "percent" && (
|
{mode === "percent" && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<span className="label">{t("val.percent")}</span>
|
<span className="label">{t("val.percent")}</span>
|
||||||
@@ -158,6 +200,7 @@ function StationForm({
|
|||||||
<span className="label">{t("val.maxPerDay")}</span>
|
<span className="label">{t("val.maxPerDay")}</span>
|
||||||
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
{!hideUsers && (
|
||||||
<div>
|
<div>
|
||||||
<div className="label">{t("val.users")}</div>
|
<div className="label">{t("val.users")}</div>
|
||||||
<span className="hint block">{t("val.usersHint")}</span>
|
<span className="hint block">{t("val.usersHint")}</span>
|
||||||
@@ -182,6 +225,7 @@ function StationForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||||
{t("site.save")}
|
{t("site.save")}
|
||||||
@@ -220,7 +264,7 @@ export function ValidationStationsPanel({
|
|||||||
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
|
||||||
onClick={() => setTab(p.id)}
|
onClick={() => setTab(p.id)}
|
||||||
>
|
>
|
||||||
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
|
{t(stationLabelKey(p.id as StationId))}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+82
-26
@@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"
|
|||||||
import { logFailedRequest } from "./lib/logger.js";
|
import { logFailedRequest } from "./lib/logger.js";
|
||||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||||
import { inTauri } from "./lib/tauri-env.js";
|
import { inTauri } from "./lib/tauri-env.js";
|
||||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
import type { AppLogRecord, ChargeLine, ModuleId, TillId, ValidationLine, ValidationMode } from "@parking/shared";
|
||||||
|
|
||||||
const CSRF_COOKIE = "parking_csrf";
|
const CSRF_COOKIE = "parking_csrf";
|
||||||
const CSRF_HEADER = "X-CSRF-Token";
|
const CSRF_HEADER = "X-CSRF-Token";
|
||||||
@@ -98,6 +98,9 @@ export interface SessionUser {
|
|||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
/** Optional contact email (profile metadata); null if unset. */
|
/** Optional contact email (profile metadata); null if unset. */
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
/** Effective venue modules at this site (entitled ∩ activated) — what the SPA may
|
||||||
|
* SHOW; the server enforces. See lib/modules.ts. */
|
||||||
|
modules: ModuleId[];
|
||||||
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
/** Desktop-only: the CSRF token also echoed via the (JS-unreadable, on
|
||||||
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
* desktop) parking_csrf cookie — see the file header. Absent/unused in the
|
||||||
* browser build, which reads the cookie directly instead. */
|
* browser build, which reads the cookie directly instead. */
|
||||||
@@ -210,6 +213,9 @@ export interface ManagedRole {
|
|||||||
name: string;
|
name: string;
|
||||||
builtin: boolean;
|
builtin: boolean;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
|
/** The manifest JOBS this role follows (composed from their chips). A later release
|
||||||
|
* that grows a job shows the role as "behind" it — re-applied with one click. */
|
||||||
|
jobs: string[];
|
||||||
userCount: number;
|
userCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,10 +244,10 @@ export function deleteUser(id: string): Promise<{ ok: boolean }> {
|
|||||||
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
export function fetchRoles(): Promise<{ catalog: Permission[]; roles: ManagedRole[] }> {
|
||||||
return apiFetch("/api/roles");
|
return apiFetch("/api/roles");
|
||||||
}
|
}
|
||||||
export function createRole(body: { name: string; permissions: Permission[] }): Promise<ManagedRole> {
|
export function createRole(body: { name: string; permissions: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
|
||||||
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
return apiFetch("/api/roles", { method: "POST", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
export function updateRole(id: string, body: { name?: string; permissions?: Permission[] }): Promise<ManagedRole> {
|
export function updateRole(id: string, body: { name?: string; permissions?: Permission[]; jobs?: string[] }): Promise<ManagedRole> {
|
||||||
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
return apiFetch(`/api/roles/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
}
|
}
|
||||||
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
export function deleteRole(id: string): Promise<{ ok: boolean }> {
|
||||||
@@ -1059,18 +1065,32 @@ export function deleteSubscription(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Shifts ---------------------------------------------------------------
|
// --- Shifts ---------------------------------------------------------------
|
||||||
|
// A shift is opened ON A TILL (booth | carwash …): one open shift per till, each with
|
||||||
|
// its own drawer and Z-report. Every call below takes the till, defaulting to the
|
||||||
|
// booth. See wiki/concepts/shift.md "Tills".
|
||||||
|
|
||||||
export interface ShiftStatus {
|
export type { TillId };
|
||||||
/** The requesting (logged-in) operator. */
|
|
||||||
operator: string;
|
/** One till's shift state (at most one shift open per till). */
|
||||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
export interface TillShiftStatus {
|
||||||
|
till: TillId;
|
||||||
|
/** The till's open shift (startedAt + whose), or null if none open. */
|
||||||
open: { startedAt: string; operator: string | null } | null;
|
open: { startedAt: string; operator: string | null } | null;
|
||||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
/** Live physical drawer balance (cash payments + cash movements). */
|
/** May this role open/close this till's shift (its module's `shift` guard)? */
|
||||||
|
canWork: boolean;
|
||||||
|
/** Live physical drawer balance of this till (cash payments + cash movements). */
|
||||||
drawerMinor: number;
|
drawerMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShiftStatus extends TillShiftStatus {
|
||||||
|
/** The requesting (logged-in) operator. */
|
||||||
|
operator: string;
|
||||||
|
/** Every till addressable at this site (the booth + effective modules' tills). */
|
||||||
|
tills: TillId[];
|
||||||
|
}
|
||||||
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
||||||
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
||||||
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
||||||
@@ -1079,9 +1099,13 @@ export interface ShiftSourceSplit {
|
|||||||
subscriptionTotalMinor: number;
|
subscriptionTotalMinor: number;
|
||||||
subscriptionSalesMinor: number;
|
subscriptionSalesMinor: number;
|
||||||
subscriptionWindowMinor: number;
|
subscriptionWindowMinor: number;
|
||||||
|
/** Module money that rode this till's tickets (a booth-paid wash), by module id.
|
||||||
|
* Inside cash+card, OUTSIDE the ticket bucket. Absent on pre-2026-09 reports. */
|
||||||
|
chargesByModuleMinor?: Partial<Record<string, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftReport extends ShiftSourceSplit {
|
export interface ShiftReport extends ShiftSourceSplit {
|
||||||
|
till: TillId;
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
@@ -1097,20 +1121,27 @@ export interface ShiftReport extends ShiftSourceSplit {
|
|||||||
printed: boolean;
|
printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchShift(): Promise<ShiftStatus> {
|
const tillQs = (till?: TillId) => (till && till !== "booth" ? `?till=${till}` : "");
|
||||||
return apiFetch("/api/shift/current");
|
|
||||||
|
export function fetchShift(till: TillId = "booth"): Promise<ShiftStatus> {
|
||||||
|
return apiFetch(`/api/shift/current${tillQs(till)}`);
|
||||||
}
|
}
|
||||||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
/** Every till's shift state in one read (the shift hub lists each open shift). */
|
||||||
return apiFetch("/api/shift/open", { method: "POST" });
|
export function fetchShiftTills(): Promise<{ operator: string; tills: TillShiftStatus[] }> {
|
||||||
|
return apiFetch("/api/shift/tills");
|
||||||
}
|
}
|
||||||
export function closeShift(): Promise<ShiftReport> {
|
export function openShift(till: TillId = "booth"): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||||
return apiFetch("/api/shift/close", { method: "POST" });
|
return apiFetch("/api/shift/open", { method: "POST", body: JSON.stringify({ till }) });
|
||||||
|
}
|
||||||
|
export function closeShift(till: TillId = "booth"): Promise<ShiftReport> {
|
||||||
|
return apiFetch("/api/shift/close", { method: "POST", body: JSON.stringify({ till }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||||
* snapshot instant. */
|
* snapshot instant. */
|
||||||
export interface XReport extends ShiftSourceSplit {
|
export interface XReport extends ShiftSourceSplit {
|
||||||
|
till: TillId;
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string; // = asOf
|
endedAt: string; // = asOf
|
||||||
@@ -1126,8 +1157,8 @@ export interface XReport extends ShiftSourceSplit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||||
export async function fetchShiftReport(): Promise<XReport | null> {
|
export async function fetchShiftReport(till: TillId = "booth"): Promise<XReport | null> {
|
||||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
return (await apiFetch<XReport | undefined>(`/api/shift/report${tillQs(till)}`)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||||
@@ -1141,6 +1172,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
|||||||
export interface DrawerMovement {
|
export interface DrawerMovement {
|
||||||
id: string;
|
id: string;
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
|
/** Which drawer the cash moved in/out of. */
|
||||||
|
till: TillId;
|
||||||
/** Positive magnitude; direction is the type. */
|
/** Positive magnitude; direction is the type. */
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
@@ -1162,8 +1195,11 @@ export function recordDrawerMovement(args: {
|
|||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
|
/** Which drawer (default: the booth). */
|
||||||
|
till?: TillId;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
type: "cash_in" | "cash_out";
|
type: "cash_in" | "cash_out";
|
||||||
|
till: TillId;
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
voucherNo: string;
|
voucherNo: string;
|
||||||
balanceMinor: number;
|
balanceMinor: number;
|
||||||
@@ -1174,18 +1210,21 @@ export function recordDrawerMovement(args: {
|
|||||||
|
|
||||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||||
* and may filter by status (the pending review queue). */
|
* and may filter by status (the pending review queue). */
|
||||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
export function fetchDrawerMovements(status?: MovementStatus, till?: TillId): Promise<{
|
||||||
movements: DrawerMovement[];
|
movements: DrawerMovement[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
}> {
|
}> {
|
||||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
const qs = new URLSearchParams();
|
||||||
return apiFetch(`/api/drawer/movements${qs}`);
|
if (status) qs.set("status", status);
|
||||||
|
if (till) qs.set("till", till);
|
||||||
|
const q = qs.toString();
|
||||||
|
return apiFetch(`/api/drawer/movements${q ? `?${q}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
/** A till's physical drawer balance NOW (cash payments + vouchers over the whole chain
|
||||||
* the amount that carries across shifts). */
|
* — the amount that carries across that till's shifts). */
|
||||||
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
export function fetchDrawerBalance(till: TillId = "booth"): Promise<{ till: TillId; balanceMinor: number; currency: string | null }> {
|
||||||
return apiFetch("/api/drawer/balance");
|
return apiFetch(`/api/drawer/balance${tillQs(till)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||||
@@ -1201,6 +1240,8 @@ export function reviewDrawerMovement(args: {
|
|||||||
export interface ShiftSummary extends ShiftSourceSplit {
|
export interface ShiftSummary extends ShiftSourceSplit {
|
||||||
id: string;
|
id: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
/** The till this shift reconciled. */
|
||||||
|
till: TillId;
|
||||||
operator: string;
|
operator: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
@@ -1218,16 +1259,19 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
|||||||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||||||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||||||
* which the server applied, so the UI can show/hide the filter. */
|
* which the server applied, so the UI can show/hide the filter. */
|
||||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
export function fetchShifts(params: { operator?: string; from?: string; to?: string; till?: TillId } = {}): Promise<{
|
||||||
shifts: ShiftSummary[];
|
shifts: ShiftSummary[];
|
||||||
scope: "all" | "self";
|
scope: "all" | "self";
|
||||||
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||||
operators?: string[];
|
operators?: string[];
|
||||||
|
/** Every till addressable at this site — more than one → show the till filter/badges. */
|
||||||
|
tills: TillId[];
|
||||||
}> {
|
}> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.operator) qs.set("operator", params.operator);
|
if (params.operator) qs.set("operator", params.operator);
|
||||||
if (params.from) qs.set("from", params.from);
|
if (params.from) qs.set("from", params.from);
|
||||||
if (params.to) qs.set("to", params.to);
|
if (params.to) qs.set("to", params.to);
|
||||||
|
if (params.till) qs.set("till", params.till);
|
||||||
const q = qs.toString();
|
const q = qs.toString();
|
||||||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||||||
}
|
}
|
||||||
@@ -1257,6 +1301,12 @@ export interface SiteConfig {
|
|||||||
bypassPresenceRadar: boolean;
|
bypassPresenceRadar: boolean;
|
||||||
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
/** Entry presence-gate bypass: drop camera detection as an entry-button requirement. */
|
||||||
bypassPresenceCamera: boolean;
|
bypassPresenceCamera: boolean;
|
||||||
|
/** Effective venue modules (entitled ∩ activated). */
|
||||||
|
modules: ModuleId[];
|
||||||
|
/** What this deployment is entitled to (vendor-set) — the toggles offered in Setup. */
|
||||||
|
modulesEntitled: ModuleId[];
|
||||||
|
/** What the site admin has activated. Send the full desired set via saveSiteConfig. */
|
||||||
|
modulesActivated: ModuleId[];
|
||||||
parkName: string | null;
|
parkName: string | null;
|
||||||
operatorName: string | null;
|
operatorName: string | null;
|
||||||
/** NIUS — Albanian tax/identification number. */
|
/** NIUS — Albanian tax/identification number. */
|
||||||
@@ -1287,7 +1337,7 @@ export interface DeviceStatus {
|
|||||||
category: "access" | "reader" | "camera" | "printer" | "vision";
|
category: "access" | "reader" | "camera" | "printer" | "vision";
|
||||||
/** Role/direction token for the footer label (NOT the vendor) — the client
|
/** Role/direction token for the footer label (NOT the vendor) — the client
|
||||||
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
* localises it next to the category, e.g. "Lexuesi hyrje", "Printer kabina". */
|
||||||
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | null;
|
roleKind: "entry" | "exit" | "both" | "mixed" | "lane" | "booth" | "wash" | null;
|
||||||
state: "ready" | "degraded" | "offline";
|
state: "ready" | "degraded" | "offline";
|
||||||
detail?: string;
|
detail?: string;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
@@ -1307,15 +1357,18 @@ export type { AppLogRecord };
|
|||||||
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
/** Recent ledger events, newest first (default 100, max 1000). Used for the
|
||||||
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)
|
||||||
* scopes to events at/after that instant — the booth passes the current shift's
|
* scopes to events at/after that instant — the booth passes the current shift's
|
||||||
* start so the feed shows ONLY this shift's activity. */
|
* start so the feed shows ONLY this shift's activity. `till` keeps one till's activity
|
||||||
|
* (the server applies the shared tillOfEvent rule) — a shift's log is per till. */
|
||||||
export function fetchEvents(
|
export function fetchEvents(
|
||||||
limit = 100,
|
limit = 100,
|
||||||
since?: string,
|
since?: string,
|
||||||
until?: string,
|
until?: string,
|
||||||
|
till?: TillId,
|
||||||
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
): Promise<{ events: import("@parking/shared").LedgerEvent[] }> {
|
||||||
const qs = new URLSearchParams({ limit: String(limit) });
|
const qs = new URLSearchParams({ limit: String(limit) });
|
||||||
if (since) qs.set("since", since);
|
if (since) qs.set("since", since);
|
||||||
if (until) qs.set("until", until);
|
if (until) qs.set("until", until);
|
||||||
|
if (till) qs.set("till", till);
|
||||||
return apiFetch(`/api/events?${qs.toString()}`);
|
return apiFetch(`/api/events?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,6 +1403,9 @@ export interface SessionLookup {
|
|||||||
grossMinor: number | null;
|
grossMinor: number | null;
|
||||||
discountMinor: number | null;
|
discountMinor: number | null;
|
||||||
validationLines: ValidationLine[];
|
validationLines: ValidationLine[];
|
||||||
|
/** Module charges folded into `amountMinor` (e.g. a car wash paid at the booth). */
|
||||||
|
chargeLines: ChargeLine[];
|
||||||
|
chargesMinor: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ export function formatMoney(amountMinor: number, currency: string): string {
|
|||||||
export function formatDuration(fromIso: string, toIso: string): string {
|
export function formatDuration(fromIso: string, toIso: string): string {
|
||||||
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||||
const mins = Math.floor(ms / 60_000);
|
return formatMinutesLong(Math.floor(ms / 60_000));
|
||||||
const h = Math.floor(mins / 60);
|
}
|
||||||
|
|
||||||
|
/** "Xy Xd Xh Xm" with the leading zero units dropped — a stay of 1797h reads as
|
||||||
|
* "74d 21h 23m", not a wall of hours (a stale/forgotten ticket is a real case on a
|
||||||
|
* booth; the number should still be readable at a glance). Years only past 365 days. */
|
||||||
|
export function formatMinutesLong(totalMinutes: number): string {
|
||||||
|
const mins = Math.max(0, Math.floor(totalMinutes));
|
||||||
|
const y = Math.floor(mins / (365 * 24 * 60));
|
||||||
|
const d = Math.floor((mins % (365 * 24 * 60)) / (24 * 60));
|
||||||
|
const h = Math.floor((mins % (24 * 60)) / 60);
|
||||||
const m = mins % 60;
|
const m = mins % 60;
|
||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
const parts: string[] = [];
|
||||||
|
if (y > 0) parts.push(`${y}y`);
|
||||||
|
if (y > 0 || d > 0) parts.push(`${d}d`);
|
||||||
|
if (y > 0 || d > 0 || h > 0) parts.push(`${h}h`);
|
||||||
|
parts.push(`${m}m`);
|
||||||
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||||
@@ -41,9 +55,7 @@ export function formatCountdown(untilIso: string | null, nowMs: number = Date.no
|
|||||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||||
export function formatMinutes(mins: number): string {
|
export function formatMinutes(mins: number): string {
|
||||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||||
const m = Math.round(mins);
|
return formatMinutesLong(Math.round(mins));
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||||
|
|||||||
+134
-3
@@ -56,11 +56,106 @@ export const en: Catalog = {
|
|||||||
changeServer: "Change server",
|
changeServer: "Change server",
|
||||||
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
|
changeServerConfirm: "This signs you out and asks for a new server address on next launch. Continue?",
|
||||||
},
|
},
|
||||||
|
modules: {
|
||||||
|
sectionTitle: "Modules",
|
||||||
|
sectionHint: "Optional parts of the system this site uses. What can be switched on here is decided at deployment; switching one off hides it and refuses its actions — nothing is deleted.",
|
||||||
|
required: "Always on.",
|
||||||
|
requires: "Requires: {{deps}}",
|
||||||
|
name: {
|
||||||
|
parking: "Parking",
|
||||||
|
validation: "Merchant validations (Bar)",
|
||||||
|
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: {
|
||||||
|
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.",
|
||||||
|
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
|
||||||
|
drawerNow: "Wash drawer now",
|
||||||
|
intake: "New wash",
|
||||||
|
ticketPh: "Parking ticket (scan or type)",
|
||||||
|
lookup: "Look up",
|
||||||
|
notFound: "No session for this ticket.",
|
||||||
|
closed: "This session is already closed.",
|
||||||
|
ticket: "Ticket",
|
||||||
|
plate: "Plate",
|
||||||
|
enteredAt: "Entered",
|
||||||
|
alreadyOpen: "This ticket already has an open wash order.",
|
||||||
|
category: "Vehicle category",
|
||||||
|
service: "Service",
|
||||||
|
price: "Price",
|
||||||
|
noPrice: "no price set for this pair",
|
||||||
|
payAt: "Payment",
|
||||||
|
payAtBooth: "At the booth",
|
||||||
|
payAtBay: "At the bay",
|
||||||
|
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
|
||||||
|
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
|
||||||
|
create: "Create order",
|
||||||
|
created: "Order created.",
|
||||||
|
queue: "Open orders",
|
||||||
|
empty: "Nothing to wash.",
|
||||||
|
time: "Time",
|
||||||
|
what: "Wash",
|
||||||
|
status: "Status",
|
||||||
|
statusOpen: "in progress",
|
||||||
|
statusDone: "done",
|
||||||
|
paid: "paid",
|
||||||
|
unpaid: "unpaid",
|
||||||
|
done: "Done",
|
||||||
|
payCash: "Paid cash",
|
||||||
|
payCard: "Paid card",
|
||||||
|
void: "Void",
|
||||||
|
voidReason: "Reason",
|
||||||
|
categories: "Vehicle categories",
|
||||||
|
services: "Services",
|
||||||
|
prices: "Prices",
|
||||||
|
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
|
||||||
|
addCategory: "category",
|
||||||
|
addService: "service",
|
||||||
|
active: "active",
|
||||||
|
save: "Save",
|
||||||
|
saved: "Saved.",
|
||||||
|
finished: "Finished",
|
||||||
|
finishedEmpty: "No finished washes yet.",
|
||||||
|
voided: "voided",
|
||||||
|
by: "By",
|
||||||
|
cash: "cash",
|
||||||
|
card: "card",
|
||||||
|
sponsorship: "Parking discount",
|
||||||
|
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
|
||||||
|
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",
|
||||||
|
reviewTitle: "Remote review",
|
||||||
|
reviewOff: "off — no collector configured for this booth",
|
||||||
|
reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned",
|
||||||
|
reviewEntrySample: "1 in {{n}} entries sampled",
|
||||||
|
reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.",
|
||||||
|
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
|
||||||
|
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",
|
||||||
prompt: "Version {{version}} is available. Install now and restart?",
|
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
wash: "Car wash",
|
||||||
|
carwash: "Car wash",
|
||||||
booth: "Booth",
|
booth: "Booth",
|
||||||
shift: "Shift",
|
shift: "Shift",
|
||||||
setup: "Setup",
|
setup: "Setup",
|
||||||
@@ -161,6 +256,7 @@ export const en: Catalog = {
|
|||||||
mixed: "entry/exit",
|
mixed: "entry/exit",
|
||||||
lane: "at lane",
|
lane: "at lane",
|
||||||
booth: "at booth",
|
booth: "at booth",
|
||||||
|
wash: "at wash desk",
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
ready: "ready",
|
ready: "ready",
|
||||||
@@ -246,6 +342,9 @@ export const en: Catalog = {
|
|||||||
evtCashReview: "REVIEW",
|
evtCashReview: "REVIEW",
|
||||||
evtConfigChange: "CONFIG",
|
evtConfigChange: "CONFIG",
|
||||||
evtValidation: "VALIDATION",
|
evtValidation: "VALIDATION",
|
||||||
|
evtCarwashOrder: "CAR WASH",
|
||||||
|
evtCarwashPayment: "WASH PAYMENT",
|
||||||
|
charges: "Extra charges",
|
||||||
decision: { authorize: "authorized", deny: "denied" },
|
decision: { authorize: "authorized", deny: "denied" },
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
evtRefused: "REFUSED",
|
evtRefused: "REFUSED",
|
||||||
@@ -310,6 +409,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)",
|
||||||
@@ -756,9 +856,8 @@ export const en: Catalog = {
|
|||||||
val: {
|
val: {
|
||||||
// /setup/site
|
// /setup/site
|
||||||
sectionTitle: "Merchant validations",
|
sectionTitle: "Merchant validations",
|
||||||
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
sectionHint: "An in-park merchant (the bar) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
|
||||||
enableBar: "Bar",
|
enableBar: "Bar",
|
||||||
enableLavazh: "Car wash",
|
|
||||||
labelName: "Receipt label",
|
labelName: "Receipt label",
|
||||||
labelNamePh: "e.g. Car wash — first hour free",
|
labelNamePh: "e.g. Car wash — first hour free",
|
||||||
mode: "Discount type",
|
mode: "Discount type",
|
||||||
@@ -766,6 +865,11 @@ export const en: Catalog = {
|
|||||||
modeTimeCredit: "First minutes free",
|
modeTimeCredit: "First minutes free",
|
||||||
modeFixed: "Amount off (typed at scan)",
|
modeFixed: "Amount off (typed at scan)",
|
||||||
modePercent: "Percent off",
|
modePercent: "Percent off",
|
||||||
|
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
|
||||||
|
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
|
||||||
|
modeWashPrice: "Wash price off the parking fee",
|
||||||
|
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
|
||||||
|
toleranceMinutes: "Tolerance after done (minutes)",
|
||||||
minutes: "Free minutes",
|
minutes: "Free minutes",
|
||||||
percent: "Percent (%)",
|
percent: "Percent (%)",
|
||||||
maxAmount: "Cap per validation",
|
maxAmount: "Cap per validation",
|
||||||
@@ -833,6 +937,20 @@ export const en: Catalog = {
|
|||||||
permCount_other: "{{count}} permissions",
|
permCount_other: "{{count}} permissions",
|
||||||
userCount_one: "{{count}} user",
|
userCount_one: "{{count}} user",
|
||||||
userCount_other: "{{count}} users",
|
userCount_other: "{{count}} users",
|
||||||
|
// Jobs — one-click permission bundles each module brings; the grid stays the fine-tune.
|
||||||
|
jobs: "Jobs",
|
||||||
|
jobsHint: "A job adds its permissions in one click; fine-tune below. Tap it again to remove them.",
|
||||||
|
lintMixedTills: "This role can open more than one till ({{tills}}) — one person, two drawers. Intended?",
|
||||||
|
lintPartialJob: "Partial \"{{job}}\": missing {{missing}} — this desk can look but not act.",
|
||||||
|
lintJobBehind: "Behind \"{{job}}\": this release added {{missing}} to the job. Tap the job chip off and on to take it, or tick it below.",
|
||||||
|
behind: "behind {{job}}",
|
||||||
|
reapply: "Update to job",
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
"booth-operator": "Booth operator",
|
||||||
|
"booth-supervisor": "Booth supervisor",
|
||||||
|
merchant: "Merchant (validation)",
|
||||||
|
"wash-operator": "Wash operator",
|
||||||
},
|
},
|
||||||
shift: {
|
shift: {
|
||||||
label: "Shift:",
|
label: "Shift:",
|
||||||
@@ -871,6 +989,7 @@ export const en: Catalog = {
|
|||||||
card: "Card:",
|
card: "Card:",
|
||||||
srcTickets: "Tickets:",
|
srcTickets: "Tickets:",
|
||||||
srcSubscriptions: "Subscriptions:",
|
srcSubscriptions: "Subscriptions:",
|
||||||
|
srcOnTicket: "{{module}} (on ticket):",
|
||||||
srcSubWindow: "out-of-window",
|
srcSubWindow: "out-of-window",
|
||||||
drawerSection: "— Drawer —",
|
drawerSection: "— Drawer —",
|
||||||
openingFloat: "Opening cash:",
|
openingFloat: "Opening cash:",
|
||||||
@@ -894,6 +1013,18 @@ export const en: Catalog = {
|
|||||||
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
||||||
openNow: "Open shift now",
|
openNow: "Open shift now",
|
||||||
opening: "Opening…",
|
opening: "Opening…",
|
||||||
|
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
|
||||||
|
tillOpen: "Open {{till}} shift",
|
||||||
|
tillClose: "Close {{till}} shift",
|
||||||
|
tillHeldByShort: "{{till}}: {{operator}}",
|
||||||
|
tillNoShift: "No {{till}} shift",
|
||||||
|
},
|
||||||
|
till: {
|
||||||
|
booth: "Booth",
|
||||||
|
carwash: "Wash",
|
||||||
|
boothLong: "Booth till",
|
||||||
|
carwashLong: "Wash till",
|
||||||
|
all: "All tills",
|
||||||
},
|
},
|
||||||
shifts: {
|
shifts: {
|
||||||
title: "Shift history",
|
title: "Shift history",
|
||||||
|
|||||||
+133
-3
@@ -59,11 +59,105 @@ export const sq = {
|
|||||||
changeServer: "Ndrysho serverin",
|
changeServer: "Ndrysho serverin",
|
||||||
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
|
changeServerConfirm: "Kjo do t'ju dalë nga sesioni dhe do kërkojë adresë të re serveri në hapjen tjetër. Vazhdo?",
|
||||||
},
|
},
|
||||||
|
modules: {
|
||||||
|
sectionTitle: "Modulet",
|
||||||
|
sectionHint: "Pjesët opsionale të sistemit që përdor ky park. Çfarë mund të aktivizohet këtu vendoset gjatë instalimit; çaktivizimi e fsheh modulin dhe refuzon veprimet e tij — asgjë nuk fshihet.",
|
||||||
|
required: "Gjithmonë aktiv.",
|
||||||
|
requires: "Kërkon: {{deps}}",
|
||||||
|
name: {
|
||||||
|
parking: "Parkimi",
|
||||||
|
validation: "Validime tregtare (Bar)",
|
||||||
|
carwash: "Lavazh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vehicleClass: {
|
||||||
|
car: "veturë",
|
||||||
|
sedan: "sedan",
|
||||||
|
hatchback: "hatchback",
|
||||||
|
suv: "SUV",
|
||||||
|
minivan: "minivan",
|
||||||
|
pickup: "pikap",
|
||||||
|
van: "furgon",
|
||||||
|
truck: "kamion",
|
||||||
|
bus: "autobus",
|
||||||
|
motorcycle: "motor",
|
||||||
|
},
|
||||||
|
wash: {
|
||||||
|
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.",
|
||||||
|
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
|
||||||
|
drawerNow: "Arka e lavazhit tani",
|
||||||
|
intake: "Lavazh i ri",
|
||||||
|
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
|
||||||
|
lookup: "Kërko",
|
||||||
|
notFound: "Nuk ka sesion për këtë biletë.",
|
||||||
|
closed: "Ky sesion është mbyllur.",
|
||||||
|
ticket: "Bileta",
|
||||||
|
plate: "Targa",
|
||||||
|
enteredAt: "Hyri",
|
||||||
|
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
|
||||||
|
category: "Kategoria e mjetit",
|
||||||
|
service: "Shërbimi",
|
||||||
|
price: "Çmimi",
|
||||||
|
noPrice: "nuk ka çmim për këtë kombinim",
|
||||||
|
payAt: "Pagesa",
|
||||||
|
payAtBooth: "Në kabinë",
|
||||||
|
payAtBay: "Në lavazh",
|
||||||
|
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
|
||||||
|
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
|
||||||
|
create: "Krijo porosinë",
|
||||||
|
created: "Porosia u krijua.",
|
||||||
|
queue: "Porositë e hapura",
|
||||||
|
empty: "Asgjë për të larë.",
|
||||||
|
time: "Ora",
|
||||||
|
what: "Lavazhi",
|
||||||
|
status: "Statusi",
|
||||||
|
statusOpen: "në proces",
|
||||||
|
statusDone: "mbaroi",
|
||||||
|
paid: "paguar",
|
||||||
|
unpaid: "papaguar",
|
||||||
|
done: "Mbaroi",
|
||||||
|
payCash: "Paguar cash",
|
||||||
|
payCard: "Paguar me kartë",
|
||||||
|
void: "Anulo",
|
||||||
|
voidReason: "Arsyeja",
|
||||||
|
categories: "Kategoritë e mjeteve",
|
||||||
|
services: "Shërbimet",
|
||||||
|
prices: "Çmimet",
|
||||||
|
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
|
||||||
|
addCategory: "kategori",
|
||||||
|
addService: "shërbim",
|
||||||
|
active: "aktiv",
|
||||||
|
save: "Ruaj",
|
||||||
|
saved: "U ruajt.",
|
||||||
|
finished: "Të mbaruara",
|
||||||
|
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
|
||||||
|
voided: "anuluar",
|
||||||
|
by: "Nga",
|
||||||
|
cash: "cash",
|
||||||
|
card: "kartë",
|
||||||
|
sponsorship: "Zbritje parkimi",
|
||||||
|
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
|
||||||
|
sponsorshipLabel: "Lavazh",
|
||||||
|
visionSaw: "Kamera pa",
|
||||||
|
visionUnmapped: "pa kategori të lidhur",
|
||||||
|
visionClasses: "Klasat e kamerës",
|
||||||
|
reviewTitle: "Shqyrtim në distancë",
|
||||||
|
reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë",
|
||||||
|
reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura",
|
||||||
|
reviewEntrySample: "1 në {{n}} hyrje merret mostër",
|
||||||
|
reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.",
|
||||||
|
visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.",
|
||||||
|
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",
|
||||||
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis?",
|
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
wash: "Lavazh",
|
||||||
|
carwash: "Lavazh",
|
||||||
booth: "Kabina",
|
booth: "Kabina",
|
||||||
shift: "Turni",
|
shift: "Turni",
|
||||||
setup: "Konfigurimi",
|
setup: "Konfigurimi",
|
||||||
@@ -164,6 +258,7 @@ export const sq = {
|
|||||||
mixed: "hyrje/dalje",
|
mixed: "hyrje/dalje",
|
||||||
lane: "në korsi",
|
lane: "në korsi",
|
||||||
booth: "në kabinë",
|
booth: "në kabinë",
|
||||||
|
wash: "në lavazh",
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
ready: "gati",
|
ready: "gati",
|
||||||
@@ -251,6 +346,9 @@ export const sq = {
|
|||||||
evtCashReview: "SHQYRTIM",
|
evtCashReview: "SHQYRTIM",
|
||||||
evtConfigChange: "KONFIG",
|
evtConfigChange: "KONFIG",
|
||||||
evtValidation: "VALIDIM",
|
evtValidation: "VALIDIM",
|
||||||
|
evtCarwashOrder: "LAVAZH",
|
||||||
|
evtCarwashPayment: "PAGESË LAVAZHI",
|
||||||
|
charges: "Shtesa",
|
||||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
evtRefused: "REFUZUAR",
|
evtRefused: "REFUZUAR",
|
||||||
@@ -314,6 +412,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)",
|
||||||
@@ -769,9 +868,8 @@ export const sq = {
|
|||||||
val: {
|
val: {
|
||||||
// /setup/site
|
// /setup/site
|
||||||
sectionTitle: "Validime tregtare",
|
sectionTitle: "Validime tregtare",
|
||||||
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
|
sectionHint: "Bari brenda parkut skanon biletën e hyrjes dhe bën zbritje — pagesa dhe fatura bëhen në kabinë.",
|
||||||
enableBar: "Bar",
|
enableBar: "Bar",
|
||||||
enableLavazh: "Lavazh",
|
|
||||||
labelName: "Etiketa në faturë",
|
labelName: "Etiketa në faturë",
|
||||||
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
labelNamePh: "p.sh. Lavazh — 1 orë falas",
|
||||||
mode: "Lloji i zbritjes",
|
mode: "Lloji i zbritjes",
|
||||||
@@ -779,6 +877,11 @@ export const sq = {
|
|||||||
modeTimeCredit: "Minutat e para falas",
|
modeTimeCredit: "Minutat e para falas",
|
||||||
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||||
modePercent: "Zbritje në përqindje",
|
modePercent: "Zbritje në përqindje",
|
||||||
|
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
|
||||||
|
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
|
||||||
|
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
|
||||||
|
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
|
||||||
|
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
|
||||||
minutes: "Minuta falas",
|
minutes: "Minuta falas",
|
||||||
percent: "Përqindja (%)",
|
percent: "Përqindja (%)",
|
||||||
maxAmount: "Tavani i zbritjes për validim",
|
maxAmount: "Tavani i zbritjes për validim",
|
||||||
@@ -847,6 +950,20 @@ export const sq = {
|
|||||||
permCount_other: "{{count}} leje",
|
permCount_other: "{{count}} leje",
|
||||||
userCount_one: "{{count}} përdorues",
|
userCount_one: "{{count}} përdorues",
|
||||||
userCount_other: "{{count}} përdorues",
|
userCount_other: "{{count}} përdorues",
|
||||||
|
// Punët — pako lejesh që sjell çdo modul; rrjeta poshtë mbetet për rregullim të imët.
|
||||||
|
jobs: "Punët",
|
||||||
|
jobsHint: "Një punë shton lejet e saj me një klik; rregulloji poshtë. Kliko sërish për t'i hequr.",
|
||||||
|
lintMixedTills: "Ky rol mund të hapë më shumë se një arkë ({{tills}}) — një person, dy arka. E qëllimshme?",
|
||||||
|
lintPartialJob: "\"{{job}}\" e pjesshme: mungojnë {{missing}} — kjo tavolinë sheh, por nuk vepron.",
|
||||||
|
lintJobBehind: "Pas \"{{job}}\": ky version i shtoi punës {{missing}}. Hiqe dhe rivendose punën për t'i marrë, ose shënoji poshtë.",
|
||||||
|
behind: "pas {{job}}",
|
||||||
|
reapply: "Përditëso sipas punës",
|
||||||
|
},
|
||||||
|
jobs: {
|
||||||
|
"booth-operator": "Operator kabine",
|
||||||
|
"booth-supervisor": "Përgjegjës kabine",
|
||||||
|
merchant: "Tregtar (validime)",
|
||||||
|
"wash-operator": "Operator lavazhi",
|
||||||
},
|
},
|
||||||
shift: {
|
shift: {
|
||||||
label: "Turni:",
|
label: "Turni:",
|
||||||
@@ -885,6 +1002,7 @@ export const sq = {
|
|||||||
card: "Kartë:",
|
card: "Kartë:",
|
||||||
srcTickets: "Bileta:",
|
srcTickets: "Bileta:",
|
||||||
srcSubscriptions: "Abonime:",
|
srcSubscriptions: "Abonime:",
|
||||||
|
srcOnTicket: "{{module}} (në biletë):",
|
||||||
srcSubWindow: "jashtë orarit",
|
srcSubWindow: "jashtë orarit",
|
||||||
drawerSection: "— Arka —",
|
drawerSection: "— Arka —",
|
||||||
openingFloat: "Arka fillestare:",
|
openingFloat: "Arka fillestare:",
|
||||||
@@ -908,6 +1026,18 @@ export const sq = {
|
|||||||
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
||||||
openNow: "Hap turnin tani",
|
openNow: "Hap turnin tani",
|
||||||
opening: "Duke hapur…",
|
opening: "Duke hapur…",
|
||||||
|
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
|
||||||
|
tillOpen: "Hap turnin e {{till}}",
|
||||||
|
tillClose: "Mbyll turnin e {{till}}",
|
||||||
|
tillHeldByShort: "{{till}}: {{operator}}",
|
||||||
|
tillNoShift: "Pa turn {{till}}",
|
||||||
|
},
|
||||||
|
till: {
|
||||||
|
booth: "kabinës",
|
||||||
|
carwash: "lavazhit",
|
||||||
|
boothLong: "Arka e kabinës",
|
||||||
|
carwashLong: "Arka e lavazhit",
|
||||||
|
all: "Të gjitha arkat",
|
||||||
},
|
},
|
||||||
shifts: {
|
shifts: {
|
||||||
title: "Historiku i turneve",
|
title: "Historiku i turneve",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { AnyRoute } from "@tanstack/react-router";
|
||||||
|
import { watchPermissions, type ModuleId } from "@parking/shared";
|
||||||
|
import { can, type Permission, type SessionUser } from "../api.js";
|
||||||
|
import type { rootRoute } from "../router.js";
|
||||||
|
|
||||||
|
/** The app's root route (type only — a runtime import here would be a cycle). */
|
||||||
|
export type RootRoute = typeof rootRoute;
|
||||||
|
|
||||||
|
// Venue modules — the web side. The server ENFORCES the effective set
|
||||||
|
// (requireModule); this file only decides what to SHOW. A module's nav entries and
|
||||||
|
// routes live in its own folder (apps/web/src/modules/<id>/index.tsx) and are
|
||||||
|
// discovered through WEB_MODULES below, so router.tsx never names a module's screens.
|
||||||
|
// See wiki/decisions/venue-modules.md.
|
||||||
|
|
||||||
|
/** Is the module effective for this session? `modules` comes from /api/auth/me
|
||||||
|
* (entitled ∩ activated); a server too old to send it hides every module rather
|
||||||
|
* than showing something it would 403 — fail closed on the display side too. */
|
||||||
|
export function moduleOn(user: SessionUser | null, id: ModuleId): boolean {
|
||||||
|
return !!user && Array.isArray(user.modules) && user.modules.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** May this role open the live WebSocket at all? Any watch permission (core event/
|
||||||
|
* session/device read, or an effective module's own feed permission). The server
|
||||||
|
* admits by the same rule and then filters what it pushes. NOT report:read. */
|
||||||
|
export function canWatchFeed(user: SessionUser | null): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
const effective = Array.isArray(user.modules) ? user.modules : [];
|
||||||
|
return watchPermissions(effective).some((p) => can(user, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebModuleNav {
|
||||||
|
to: string;
|
||||||
|
/** i18n key for the header label. */
|
||||||
|
labelKey: string;
|
||||||
|
/** Shown only if the role holds this permission (and the module is on). */
|
||||||
|
perm: Permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebModule {
|
||||||
|
id: ModuleId;
|
||||||
|
/** Header nav entries, in display order. */
|
||||||
|
nav: readonly WebModuleNav[];
|
||||||
|
/** Where a user whose role has NO booth (`session:read`) lands after login, if the
|
||||||
|
* module is on and the role holds `perm` — e.g. the wash desk for a wash operator,
|
||||||
|
* the scan screen for a merchant. First match in WEB_MODULES order wins. */
|
||||||
|
landing?: WebModuleNav;
|
||||||
|
/** Build this module's routes under the given root. Called once at router
|
||||||
|
* assembly; each route's own beforeLoad must gate on moduleOn + permission. */
|
||||||
|
routes(root: RootRoute): AnyRoute[];
|
||||||
|
/** Setup tabs (under /setup), if the module has admin configuration. */
|
||||||
|
setupNav?: readonly WebModuleNav[];
|
||||||
|
/** Build this module's routes under the /setup layout route. */
|
||||||
|
setupRoutes?(setup: AnyRoute): AnyRoute[];
|
||||||
|
}
|
||||||
@@ -15,8 +15,9 @@ import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
|||||||
|
|
||||||
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
/** Server → client message shapes (mirror routes/ws.ts OutMsg). */
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence }
|
// Parts a role may not see arrive as null (the server filters per role — ws.ts).
|
||||||
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy }
|
| { kind: "hello"; occupancy: Occupancy | null; devices: DeviceStatus[] | null; lanes: LaneStatus | null; radar: LanePresence | null }
|
||||||
|
| { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy | null }
|
||||||
| { kind: "printer-status"; event: unknown }
|
| { kind: "printer-status"; event: unknown }
|
||||||
| { kind: "device-status"; event: DeviceStatus }
|
| { kind: "device-status"; event: DeviceStatus }
|
||||||
| { kind: "lane-status"; lanes: LaneStatus }
|
| { kind: "lane-status"; lanes: LaneStatus }
|
||||||
@@ -67,7 +68,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
return; // ignore malformed frames
|
return; // ignore malformed frames
|
||||||
}
|
}
|
||||||
if (msg.kind === "hello") {
|
if (msg.kind === "hello") {
|
||||||
setOccupancy(msg.occupancy);
|
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||||
// Initial device-status snapshot for the footer.
|
// Initial device-status snapshot for the footer.
|
||||||
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
if (Array.isArray(msg.devices)) setDevices(msg.devices);
|
||||||
if (msg.lanes) setLanes(msg.lanes);
|
if (msg.lanes) setLanes(msg.lanes);
|
||||||
@@ -84,7 +85,7 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
patchPlate(msg.plate.identity, msg.plate.plate);
|
patchPlate(msg.plate.identity, msg.plate.plate);
|
||||||
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
} else if (msg.kind === "ledger") {
|
} else if (msg.kind === "ledger") {
|
||||||
setOccupancy(msg.occupancy);
|
if (msg.occupancy) setOccupancy(msg.occupancy);
|
||||||
pushEvent(msg.event);
|
pushEvent(msg.event);
|
||||||
// Keep Query authoritative: the durable event list, occupancy totals,
|
// Keep Query authoritative: the durable event list, occupancy totals,
|
||||||
// and active-sessions list refetch on the next read instead of trusting
|
// and active-sessions list refetch on the next read instead of trusting
|
||||||
@@ -99,7 +100,8 @@ export function useLiveFeed(enabled: boolean = true): void {
|
|||||||
msg.event.type === "shift_z_report" ||
|
msg.event.type === "shift_z_report" ||
|
||||||
msg.event.type === "cash_movement" ||
|
msg.event.type === "cash_movement" ||
|
||||||
msg.event.type === "cash_in" ||
|
msg.event.type === "cash_in" ||
|
||||||
msg.event.type === "cash_out"
|
msg.event.type === "cash_out" ||
|
||||||
|
msg.event.type === "carwash_payment"
|
||||||
) {
|
) {
|
||||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchShift, type ShiftStatus } from "../api.js";
|
import { fetchShift, type ShiftStatus, type TillId } from "../api.js";
|
||||||
import { qk } from "./query.js";
|
import { qk } from "./query.js";
|
||||||
|
|
||||||
// Shared shift status for the whole app — the header control, the booth screen's
|
// Shared shift status for the whole app — the header control, the booth screen's
|
||||||
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
||||||
// they never disagree about whether a shift is open and whose it is. A shift is a
|
// they never disagree about whether a shift is open and whose it is. A shift is a
|
||||||
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
|
// per-TILL single-open accountability period (at most one open per till). The
|
||||||
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
|
// default till is the booth; the wash desk reads its own (`useShift("carwash")`).
|
||||||
// live without polling. See wiki/concepts/shift.md.
|
// The WS invalidates qk.shift (a prefix, so every till) on shift_open/shift_z_report/
|
||||||
|
// cash movements, so this stays live without polling. See wiki/concepts/shift.md.
|
||||||
|
|
||||||
export interface ShiftState {
|
export interface ShiftState {
|
||||||
/** Raw status from the server (null while loading / on error). */
|
/** Raw status from the server (null while loading / on error). */
|
||||||
status: ShiftStatus | undefined;
|
status: ShiftStatus | undefined;
|
||||||
/** Is ANY shift open site-wide? */
|
/** Is a shift open on this till? */
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
||||||
isMine: boolean;
|
isMine: boolean;
|
||||||
@@ -25,8 +26,12 @@ export interface ShiftState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShift(): ShiftState {
|
/** Query key of one till's shift status — under the qk.shift prefix so the WS
|
||||||
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
|
* invalidation reaches every till. */
|
||||||
|
export const shiftKey = (till: TillId) => [...qk.shift, "current", till] as const;
|
||||||
|
|
||||||
|
export function useShift(till: TillId = "booth"): ShiftState {
|
||||||
|
const q = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||||||
const s = q.data;
|
const s = q.data;
|
||||||
const isOpen = s?.open != null;
|
const isOpen = s?.open != null;
|
||||||
const isMine = s?.isMine ?? false;
|
const isMine = s?.isMine ?? false;
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
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 { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||||
|
import { fetchCarwashReviewStatus, fetchCarwashSettings, saveCarwashSettings, type CarwashReviewStatus, type CarwashSettingsView } from "./api.js";
|
||||||
|
|
||||||
|
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||||
|
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||||
|
// validation program (id "carwash"), composed with the same editor the merchant
|
||||||
|
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
|
||||||
|
|
||||||
|
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 toMinor = (s: string): number | null => {
|
||||||
|
const v = s.trim();
|
||||||
|
if (v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ListEditor({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
addLabel,
|
||||||
|
visionMap,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: Item[];
|
||||||
|
onChange: (items: Item[]) => void;
|
||||||
|
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). The chips
|
||||||
|
* show the CANONICAL ids (the model's fixed vocabulary, a code constant), never a
|
||||||
|
* translation, so they read as what they are: not site text (user, 2026-09-06). */
|
||||||
|
visionMap?: boolean;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<div key={it.id ?? `new-${i}`} className="grid gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={it.name}
|
||||||
|
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-term-amber"
|
||||||
|
checked={it.active}
|
||||||
|
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
|
||||||
|
/>
|
||||||
|
{t("wash.active")}
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</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" title={t("wash.visionClassesHint")}>{t("wash.visionClasses")}</span>
|
||||||
|
{VEHICLE_CLASSES.map((cls) => {
|
||||||
|
const on = (it.visionClasses ?? []).includes(cls);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm font-mono lowercase ${on ? "btn-primary" : "btn-ghost"}`}
|
||||||
|
title={t(`vehicleClass.${cls}`)}
|
||||||
|
onClick={() => toggleClass(i, cls)}
|
||||||
|
>
|
||||||
|
{cls}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||||
|
+ {addLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||||
|
const [categories, setCategories] = useState<Item[]>([]);
|
||||||
|
const [services, setServices] = useState<Item[]>([]);
|
||||||
|
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
|
||||||
|
* id yet, so the matrix keys use the row INDEX until saved. */
|
||||||
|
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||||
|
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
|
||||||
|
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 [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||||
|
const [review, setReview] = useState<CarwashReviewStatus | null>(null);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
fetchCarwashSettings()
|
||||||
|
.then((s) => {
|
||||||
|
setSettings(s);
|
||||||
|
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 })));
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||||
|
setPrices(p);
|
||||||
|
setPayAt(s.payAt);
|
||||||
|
setThreshold(String(Math.round(s.visionThreshold * 100)));
|
||||||
|
})
|
||||||
|
.catch((e) => setMsg((e as Error).message));
|
||||||
|
fetchCarwashReviewStatus().then(setReview).catch(() => {});
|
||||||
|
fetchValidationPrograms()
|
||||||
|
.then((r) => {
|
||||||
|
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||||
|
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const listBody = {
|
||||||
|
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 })),
|
||||||
|
};
|
||||||
|
// New rows have no id until the server assigns one, and the price matrix is keyed
|
||||||
|
// by ids — so save the lists first, map each new row to the id that came back (the
|
||||||
|
// server returns rows in the order sent), then save the prices in a second call.
|
||||||
|
// One button, two requests; the user just sees "Saved."
|
||||||
|
let cats = categories;
|
||||||
|
let svcs = services;
|
||||||
|
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
|
||||||
|
// Keep only the prices whose rows survive this save (a removed row's prices
|
||||||
|
// would be refused as unknown ids).
|
||||||
|
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
|
||||||
|
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
|
||||||
|
const first = await saveCarwashSettings({
|
||||||
|
...listBody,
|
||||||
|
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
|
||||||
|
});
|
||||||
|
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
|
||||||
|
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
|
||||||
|
}
|
||||||
|
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||||
|
categories.forEach((c, ci) =>
|
||||||
|
services.forEach((s, si) => {
|
||||||
|
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
|
||||||
|
const cid = cats[ci]?.id;
|
||||||
|
const sid = svcs[si]?.id;
|
||||||
|
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const thr = Number(threshold);
|
||||||
|
const saved = await saveCarwashSettings({
|
||||||
|
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 })),
|
||||||
|
prices: priceRows,
|
||||||
|
payAt,
|
||||||
|
...(Number.isFinite(thr) && thr >= 0 && thr <= 100 ? { visionThreshold: thr / 100 } : {}),
|
||||||
|
});
|
||||||
|
setSettings(saved);
|
||||||
|
setPayAt(saved.payAt);
|
||||||
|
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 })));
|
||||||
|
const p: Record<string, string> = {};
|
||||||
|
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||||
|
setPrices(p);
|
||||||
|
setMsg(t("wash.saved"));
|
||||||
|
} catch (e) {
|
||||||
|
setMsg((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = settings?.currency ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Two columns on a wide screen: the master data (categories with their camera-class
|
||||||
|
// chips, services, the price matrix, policies) takes the room it needs; the sponsorship
|
||||||
|
// program keeps a fixed, readable width beside it. Stacks on narrow screens.
|
||||||
|
<div className="mt-6 grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,26rem)]">
|
||||||
|
<section className="card min-w-0 p-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<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")} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t("wash.pricesHint")}</span>
|
||||||
|
{categories.length > 0 && services.length > 0 && (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="text-[0.75rem]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3 text-left text-term-muted"></th>
|
||||||
|
{services.map((s, si) => (
|
||||||
|
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((c, ci) => (
|
||||||
|
<tr key={c.id ?? `#${ci}`}>
|
||||||
|
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
|
||||||
|
{services.map((s, si) => {
|
||||||
|
const k = keyOf(c, ci, s, si);
|
||||||
|
return (
|
||||||
|
<td key={k} className="py-1 pr-3">
|
||||||
|
<input
|
||||||
|
className="input w-24 text-right tabular-nums"
|
||||||
|
value={prices[k] ?? ""}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.payAt")}</span>
|
||||||
|
<div className="flex gap-4 text-[0.75rem]">
|
||||||
|
{CARWASH_PAY_AT.map((v) => (
|
||||||
|
<label key={v} className="flex items-center gap-1.5">
|
||||||
|
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
|
||||||
|
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
|
||||||
|
</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>
|
||||||
|
{review && (
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.reviewTitle")}</span>
|
||||||
|
<span className="text-[0.75rem] tabular-nums">
|
||||||
|
{review.enabled
|
||||||
|
? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed })
|
||||||
|
: t("wash.reviewOff")}
|
||||||
|
{review.enabled && review.entrySample > 0 && <span className="ml-2 text-term-muted">· {t("wash.reviewEntrySample", { n: review.entrySample })}</span>}
|
||||||
|
{review.enabled && review.lastError && <span className="ml-2 text-term-amber">{review.lastError}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="hint">{t("wash.reviewHint")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
|
||||||
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canEdit && program && (
|
||||||
|
<section className="card min-w-0 p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
|
||||||
|
<span className="hint">{t("wash.sponsorshipHint")}</span>
|
||||||
|
<div className="mt-2">
|
||||||
|
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Tender } from "@parking/shared";
|
||||||
|
import { formatMoney } from "../../lib/format.js";
|
||||||
|
import { useShift } from "../../lib/use-shift.js";
|
||||||
|
import { ShiftButton } from "../../ShiftControl.js";
|
||||||
|
import {
|
||||||
|
createCarwashOrder,
|
||||||
|
fetchCarwashOrders,
|
||||||
|
fetchCarwashSettings,
|
||||||
|
lookupCarwashTicket,
|
||||||
|
markCarwashDone,
|
||||||
|
payCarwashAtBay,
|
||||||
|
voidCarwashOrder,
|
||||||
|
type CarwashOrderView,
|
||||||
|
type CarwashSettingsView,
|
||||||
|
type CarwashTicketLookup,
|
||||||
|
} from "./api.js";
|
||||||
|
|
||||||
|
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
|
||||||
|
// price, where the money is taken), then work the queue — a plain list of open orders,
|
||||||
|
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
|
||||||
|
// carries that till's own shift control, and the pay buttons are gated on the wash
|
||||||
|
// operator's shift (the booth's shift does not cover the bay — the two drawers
|
||||||
|
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
|
||||||
|
|
||||||
|
const QK = ["carwash", "orders"] as const;
|
||||||
|
|
||||||
|
function timeOf(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WashDesk() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||||
|
const [ticket, setTicket] = useState("");
|
||||||
|
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
|
||||||
|
const [categoryId, setCategoryId] = useState("");
|
||||||
|
const [serviceId, setServiceId] = useState("");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orders = useQuery({
|
||||||
|
queryKey: QK,
|
||||||
|
queryFn: () => fetchCarwashOrders("open"),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
|
||||||
|
// the desk can answer "did we wash that car?" without leaving the screen.
|
||||||
|
const finished = useQuery({
|
||||||
|
queryKey: [...QK, "recent"],
|
||||||
|
queryFn: () => fetchCarwashOrders("recent"),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
|
||||||
|
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
|
||||||
|
const price = useMemo(
|
||||||
|
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
|
||||||
|
[settings, categoryId, serviceId],
|
||||||
|
);
|
||||||
|
const currency = settings?.currency ?? lookup?.currency ?? null;
|
||||||
|
|
||||||
|
async function doLookup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
if (!ticket.trim()) return;
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
setMsg((err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setMsg(t("wash.created"));
|
||||||
|
setLookup(null);
|
||||||
|
setTicket("");
|
||||||
|
void invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
const done = useMutation({
|
||||||
|
mutationFn: (id: string) => markCarwashDone(id),
|
||||||
|
onSuccess: () => void invalidate(),
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
const pay = useMutation({
|
||||||
|
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
|
||||||
|
onSuccess: () => void invalidate(),
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
const voidIt = useMutation({
|
||||||
|
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
|
||||||
|
onSuccess: () => {
|
||||||
|
setVoiding(null);
|
||||||
|
void invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => setMsg((e as Error).message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCreate =
|
||||||
|
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
|
||||||
|
|
||||||
|
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
|
||||||
|
const washShift = useShift("carwash");
|
||||||
|
const canTakeMoney = washShift.isMine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||||
|
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
|
||||||
|
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
|
||||||
|
<ShiftButton till="carwash" />
|
||||||
|
{washShift.status && (
|
||||||
|
<span className="text-[0.75rem] tabular-nums text-term-muted">
|
||||||
|
{t("wash.drawerNow")}{" "}
|
||||||
|
<span className="font-semibold text-term-text">
|
||||||
|
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="basis-full text-[0.6875rem] text-term-muted">
|
||||||
|
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card w-full max-w-md p-4">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
|
||||||
|
<form onSubmit={doLookup} className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={ticket}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTicket(e.target.value);
|
||||||
|
setLookup(null);
|
||||||
|
}}
|
||||||
|
placeholder={t("wash.ticketPh")}
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
|
||||||
|
{t("wash.lookup")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
|
||||||
|
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
|
||||||
|
{lookup?.found && lookup.open && (
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-term-muted">{t("wash.ticket")}</span>
|
||||||
|
<span className="font-mono">{lookup.identity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-term-muted">{t("wash.plate")}</span>
|
||||||
|
<span className="font-mono">{lookup.plate ?? "—"}</span>
|
||||||
|
</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 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-term-muted">{t("wash.enteredAt")}</span>
|
||||||
|
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lookup.orders.filter((o) => !o.closed).length > 0 && (
|
||||||
|
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.category")}</span>
|
||||||
|
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("wash.service")}</span>
|
||||||
|
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{services.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[0.8125rem]">
|
||||||
|
<span className="text-term-muted">{t("wash.price")}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
|
||||||
|
here so the operator knows what this order will do — never chosen per order. */}
|
||||||
|
<div className="flex items-center justify-between text-[0.75rem]">
|
||||||
|
<span className="text-term-muted">{t("wash.payAt")}</span>
|
||||||
|
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
|
||||||
|
{t("wash.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card w-full max-w-3xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
|
||||||
|
</div>
|
||||||
|
{(orders.data?.orders ?? []).length === 0 ? (
|
||||||
|
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="w-full text-[0.75rem]">
|
||||||
|
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||||
|
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||||
|
<th className="py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
|
||||||
|
<tr key={o.id} className="border-t border-term-border/50">
|
||||||
|
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||||
|
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||||
|
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
|
||||||
|
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
|
||||||
|
</span>
|
||||||
|
<span className="text-term-muted"> · </span>
|
||||||
|
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
|
||||||
|
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
|
{o.status === "open" && (
|
||||||
|
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
|
||||||
|
{t("wash.done")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{o.payAt === "bay" && !o.paidAt && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
|
||||||
|
{t("wash.payCash")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
|
||||||
|
{t("wash.payCard")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!o.paidAt && (
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
|
||||||
|
{t("wash.void")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{voiding?.id === o.id && (
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={voiding.reason}
|
||||||
|
placeholder={t("wash.voidReason")}
|
||||||
|
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
|
||||||
|
{t("wash.void")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
|
||||||
|
{t("subs.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
|
||||||
|
{(finished.data ?? []).length === 0 ? (
|
||||||
|
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 overflow-x-auto">
|
||||||
|
<table className="w-full text-[0.75rem]">
|
||||||
|
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||||
|
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||||
|
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||||
|
<th className="py-1">{t("wash.by")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="text-term-muted">
|
||||||
|
{(finished.data ?? []).map((o: CarwashOrderView) => (
|
||||||
|
<tr key={o.id} className="border-t border-term-border/50">
|
||||||
|
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||||
|
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||||
|
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||||
|
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
{o.status === "void" ? (
|
||||||
|
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-term-green">
|
||||||
|
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
|
/** 0 = entry sampling off; N = one in N entry reads is queued as training material. */
|
||||||
|
entrySample: number;
|
||||||
|
}
|
||||||
|
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 }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
|
||||||
|
import type { AnyRoute } from "@tanstack/react-router";
|
||||||
|
import { can } from "../../api.js";
|
||||||
|
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
|
||||||
|
import type { RouterContext } from "../../router.js";
|
||||||
|
import { CarWashSetup } from "./CarWashSetup.js";
|
||||||
|
import { WashDesk } from "./WashDesk.js";
|
||||||
|
|
||||||
|
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
|
||||||
|
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
|
||||||
|
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
|
||||||
|
// being effective at this site AND the permission; the server enforces the same.
|
||||||
|
|
||||||
|
function gate(perm: string) {
|
||||||
|
return ({ context }: { context: unknown }) => {
|
||||||
|
const ctx = context as RouterContext;
|
||||||
|
// Bounce to the landing resolver, never straight to the booth (a wash-only role
|
||||||
|
// has no booth to land on).
|
||||||
|
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const carwashModule: WebModule = {
|
||||||
|
id: "carwash",
|
||||||
|
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
|
||||||
|
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
|
||||||
|
routes(root: RootRoute) {
|
||||||
|
const washRoute = createRoute({
|
||||||
|
getParentRoute: () => root,
|
||||||
|
path: "/wash",
|
||||||
|
beforeLoad: gate("carwash:read"),
|
||||||
|
component: WashDesk,
|
||||||
|
});
|
||||||
|
return [washRoute];
|
||||||
|
},
|
||||||
|
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
|
||||||
|
setupRoutes(setup: AnyRoute) {
|
||||||
|
const setupCarwashRoute = createRoute({
|
||||||
|
getParentRoute: () => setup,
|
||||||
|
path: "/carwash",
|
||||||
|
beforeLoad: gate("site:read"),
|
||||||
|
component: function CarWashSetupRoute() {
|
||||||
|
const { user } = useRouteContext({ strict: false }) as RouterContext;
|
||||||
|
return <CarWashSetup canEdit={can(user, "site:update")} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [setupCarwashRoute];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { WebModule } from "../lib/modules.js";
|
||||||
|
import { carwashModule } from "./carwash/index.js";
|
||||||
|
import { validationModule } from "./validation/index.js";
|
||||||
|
|
||||||
|
// The web-side module registry, in display order. Adding a module = its folder here
|
||||||
|
// + one entry below (+ the manifest in @parking/shared). router.tsx spreads these
|
||||||
|
// into the nav and the route tree and never names a module's screens itself.
|
||||||
|
// `parking` has no folder yet — its screens are still declared directly in
|
||||||
|
// router.tsx; they move behind this seam subsystem by subsystem.
|
||||||
|
export const WEB_MODULES: readonly WebModule[] = [validationModule, carwashModule];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
|
||||||
|
import { can } from "../../api.js";
|
||||||
|
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
|
||||||
|
import type { RouterContext } from "../../router.js";
|
||||||
|
import { ValidateScreen } from "../../ValidateScreen.js";
|
||||||
|
|
||||||
|
// Merchant-scan ticket validation as a venue module (kept for the Bar —
|
||||||
|
// wiki/decisions/venue-modules.md, decision 1). The merchant (bar) scan-and-validate
|
||||||
|
// screen is usually the ONLY page a merchant user's role can reach. The server
|
||||||
|
// enforces module-on + the program↔user binding on apply; the gates here are
|
||||||
|
// defence in depth / display. See wiki/concepts/validation-discounts.md.
|
||||||
|
|
||||||
|
export const validationModule: WebModule = {
|
||||||
|
id: "validation",
|
||||||
|
nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }],
|
||||||
|
landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" },
|
||||||
|
routes(root: RootRoute) {
|
||||||
|
const validateRoute = createRoute({
|
||||||
|
getParentRoute: () => root,
|
||||||
|
path: "/validate",
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const ctx = context as RouterContext;
|
||||||
|
if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) {
|
||||||
|
throw redirect({ to: "/" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: function ValidateRoute() {
|
||||||
|
const { user } = useRouteContext({ strict: false }) as RouterContext;
|
||||||
|
if (!user) return null;
|
||||||
|
return <ValidateScreen user={user} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return [validateRoute];
|
||||||
|
},
|
||||||
|
};
|
||||||
+61
-215
@@ -8,15 +8,12 @@ import {
|
|||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
import type { Lang, Permission, SessionUser, Theme } from "./api.js";
|
||||||
import {
|
import {
|
||||||
can,
|
can,
|
||||||
closeShift,
|
|
||||||
fetchShiftReport,
|
|
||||||
fetchVersion,
|
fetchVersion,
|
||||||
logout,
|
logout,
|
||||||
openShift,
|
|
||||||
setLanguagePref,
|
setLanguagePref,
|
||||||
setThemePref,
|
setThemePref,
|
||||||
setFontScalePref,
|
setFontScalePref,
|
||||||
@@ -24,15 +21,15 @@ import {
|
|||||||
FONT_SCALE_MAX,
|
FONT_SCALE_MAX,
|
||||||
FONT_SCALE_STEP,
|
FONT_SCALE_STEP,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { qk, queryClient } from "./lib/query.js";
|
import { queryClient } from "./lib/query.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
import { setLanguage } from "./lib/i18n/index.js";
|
import { setLanguage } from "./lib/i18n/index.js";
|
||||||
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
import { applyTheme, applyFontScale } from "./lib/theme.js";
|
||||||
import { useLiveFeed } from "./lib/use-live-feed.js";
|
import { useLiveFeed } from "./lib/use-live-feed.js";
|
||||||
import { inTauri } from "./lib/origin.js";
|
import { inTauri } from "./lib/origin.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
|
||||||
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
import { DeviceFooter } from "./ui/DeviceFooter.js";
|
||||||
|
import { ShiftButton } from "./ShiftControl.js";
|
||||||
import { StatusDot } from "./ui/StatusDot.js";
|
import { StatusDot } from "./ui/StatusDot.js";
|
||||||
import { BoothScreen } from "./BoothScreen.js";
|
import { BoothScreen } from "./BoothScreen.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
@@ -45,10 +42,11 @@ import { UsersManager } from "./UsersManager.js";
|
|||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { DrawerManager } from "./DrawerManager.js";
|
import { DrawerManager } from "./DrawerManager.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
import { BackupSettings } from "./BackupSettings.js";
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
import { ValidateScreen } from "./ValidateScreen.js";
|
import { WEB_MODULES } from "./modules/index.js";
|
||||||
|
import { canWatchFeed, moduleOn } from "./lib/modules.js";
|
||||||
|
import { TILL_IDS, tillGuards } from "@parking/shared";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -197,6 +195,12 @@ function SetupLayout() {
|
|||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
|
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
|
||||||
|
{WEB_MODULES.flatMap((m) =>
|
||||||
|
(m.setupNav ?? [])
|
||||||
|
.filter((n) => moduleOn(user, m.id) && show(n.perm))
|
||||||
|
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
|
||||||
|
)}
|
||||||
{show("site:read") && <VersionBadge />}
|
{show("site:read") && <VersionBadge />}
|
||||||
<DesktopVersionBadge />
|
<DesktopVersionBadge />
|
||||||
<DesktopServerButton />
|
<DesktopServerButton />
|
||||||
@@ -362,176 +366,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till).
|
||||||
* Header shift control — the site-wide single-open shift expressed as one button:
|
|
||||||
* - no shift open → "Open shift" (enabled; opens this operator's shift)
|
|
||||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
|
||||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
|
||||||
* open yours nor close theirs until they hand over).
|
|
||||||
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
|
|
||||||
*/
|
|
||||||
function ShiftButton() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
// Closing a shift signs the Z-report and is irreversible, so the header button never
|
|
||||||
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
|
||||||
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
|
||||||
const [confirmingClose, setConfirmingClose] = useState(false);
|
|
||||||
|
|
||||||
function onClick() {
|
|
||||||
if (isMine) {
|
|
||||||
setConfirmingClose(true);
|
|
||||||
} else {
|
|
||||||
void act("open");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function act(kind: "open" | "close") {
|
|
||||||
setBusy(true);
|
|
||||||
setErr(null);
|
|
||||||
try {
|
|
||||||
if (kind === "open") await openShift();
|
|
||||||
else await closeShift();
|
|
||||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
|
||||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
|
||||||
void qc.invalidateQueries({ queryKey: qk.events });
|
|
||||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
|
||||||
} catch (e) {
|
|
||||||
setErr((e as Error).message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disabled when another operator holds the shift (can't open or close).
|
|
||||||
const label = blockedByOther
|
|
||||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
|
||||||
: isMine
|
|
||||||
? t("shift.headerClose")
|
|
||||||
: t("shift.headerOpen");
|
|
||||||
const tone = blockedByOther
|
|
||||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
|
||||||
: isMine
|
|
||||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
|
||||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || blockedByOther}
|
|
||||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
|
||||||
onClick={onClick}
|
|
||||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!isOpen && (
|
|
||||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
|
||||||
)}
|
|
||||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
|
||||||
{confirmingClose && (
|
|
||||||
<CloseShiftConfirm
|
|
||||||
busy={busy}
|
|
||||||
onCancel={() => setConfirmingClose(false)}
|
|
||||||
onConfirm={async () => {
|
|
||||||
await act("close");
|
|
||||||
setConfirmingClose(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
|
|
||||||
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
|
||||||
* expected drawer before committing the irreversible Z-report. */
|
|
||||||
function CloseShiftConfirm({
|
|
||||||
busy,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
busy: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
|
|
||||||
const x = q.data;
|
|
||||||
const cur = x?.currency ?? null;
|
|
||||||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
|
||||||
<div className="text-[0.8125rem] tabular-nums">
|
|
||||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
|
||||||
{!x ? (
|
|
||||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
|
||||||
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
|
||||||
<span />
|
|
||||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
|
||||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
|
||||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
|
||||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
|
||||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
|
||||||
<span />
|
|
||||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
|
||||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
|
||||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
|
||||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
|
||||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
|
||||||
<span />
|
|
||||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="mt-3 flex justify-end gap-2">
|
|
||||||
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
|
||||||
{t("subs.cancel")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
|
||||||
{busy ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Spinner /> {t("shift.ending")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t("shift.endShift")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
|
||||||
<span
|
|
||||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
|
||||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const { user, setUser } = rootRoute.useRouteContext();
|
const { user, setUser } = rootRoute.useRouteContext();
|
||||||
@@ -540,12 +375,13 @@ function RootLayout() {
|
|||||||
// the permission its screen needs (the route guards enforce the same server-side).
|
// the permission its screen needs (the route guards enforce the same server-side).
|
||||||
const show = (perm: Permission) => can(user, perm);
|
const show = (perm: Permission) => can(user, perm);
|
||||||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
// for roles the server would accept (routes/ws.ts admits any WATCH permission:
|
||||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
// event/session/device read, or an effective module's own feed permission — and
|
||||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
// then filters what it pushes per role). A merchant validator holds none and must
|
||||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
// not even attempt it: the 403'd upgrade would reconnect on backoff forever and
|
||||||
// DeviceFooter → device:read).
|
// spam the server log. Same rule for the widgets that feed off it (StatusDot) or
|
||||||
const canWatch = show("report:read");
|
// make their own gated calls (ShiftButton → shift:read, DeviceFooter → device:read).
|
||||||
|
const canWatch = canWatchFeed(user);
|
||||||
useLiveFeed(canWatch);
|
useLiveFeed(canWatch);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -555,9 +391,14 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
|
||||||
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
|
||||||
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
|
{/* Venue-module nav entries (e.g. the Bar merchant's scan-and-validate screen,
|
||||||
grants ONLY validation:create, so this is often their whole nav. */}
|
often that role's whole nav): shown iff the module is effective at this
|
||||||
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
|
site AND the role holds the entry's permission. See lib/modules.ts. */}
|
||||||
|
{WEB_MODULES.flatMap((m) =>
|
||||||
|
m.nav
|
||||||
|
.filter((n) => moduleOn(user, m.id) && show(n.perm))
|
||||||
|
.map((n) => <NavLink key={n.to} to={n.to} label={t(n.labelKey)} />),
|
||||||
|
)}
|
||||||
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
|
||||||
user can do either. See wiki/concepts/shift.md. */}
|
user can do either. See wiki/concepts/shift.md. */}
|
||||||
{(show("drawer:create") || show("drawer:review")) && (
|
{(show("drawer:create") || show("drawer:review")) && (
|
||||||
@@ -583,6 +424,9 @@ function RootLayout() {
|
|||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
{/* The header button is the BOOTH till's, guarded by the booth's own
|
||||||
|
shift:read (a wash role holds no shift:* at all and has its own control on
|
||||||
|
the wash desk). The server resolves the same guard from the till. */}
|
||||||
{user && show("shift:read") && <ShiftButton />}
|
{user && show("shift:read") && <ShiftButton />}
|
||||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||||
@@ -624,33 +468,33 @@ const indexRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/",
|
path: "/",
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
// A merchant-only user (validation:create without the booth's session:read)
|
// Landing = the first screen this role can actually use. The booth for anyone
|
||||||
// lands on their scan-and-validate screen; everyone else on the booth.
|
// with the booth's permission; otherwise the first venue-module landing the role
|
||||||
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
|
// holds (wash desk for a wash operator, scan screen for a merchant); otherwise
|
||||||
throw redirect({ to: "/validate" });
|
// the shift hub; otherwise the profile. Every guard that bounces sends people
|
||||||
}
|
// HERE (never straight to the booth) so a booth-less role never dead-ends.
|
||||||
throw redirect({ to: "/booth" });
|
throw redirect({ to: landingFor(context.user) });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function landingFor(user: SessionUser | null): string {
|
||||||
|
if (can(user, "session:read")) return "/booth";
|
||||||
|
for (const m of WEB_MODULES) {
|
||||||
|
if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to;
|
||||||
|
}
|
||||||
|
if (can(user, "shift:read")) return "/shifts";
|
||||||
|
return "/profile";
|
||||||
|
}
|
||||||
|
|
||||||
const boothRoute = createRoute({
|
const boothRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/booth",
|
path: "/booth",
|
||||||
component: BoothScreen,
|
// The booth is the parking operator's screen; a role without session:read (a wash
|
||||||
});
|
// operator, a merchant) goes to its own landing instead of a screen that 403s.
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
|
if (!can(context.user, "session:read")) throw redirect({ to: "/" });
|
||||||
// merchant user's role can reach. The server enforces the program↔user binding on
|
|
||||||
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
|
|
||||||
const validateRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/validate",
|
|
||||||
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
|
|
||||||
component: function ValidateRoute() {
|
|
||||||
const { user } = rootRoute.useRouteContext();
|
|
||||||
if (!user) return null;
|
|
||||||
return <ValidateScreen user={user} />;
|
|
||||||
},
|
},
|
||||||
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||||
@@ -719,17 +563,16 @@ const drawerRoute = createRoute({
|
|||||||
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
// permission. Guard on the broader of the two (create) so a review-only admin still gets
|
||||||
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
// in — the redirect only fires if the user has NEITHER, which the nav already hides.
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
|
// Anyone who may read a till's drawer, record on one, or review — the component
|
||||||
throw redirect({ to: "/booth" });
|
// shows the right view per till. (canWatchFeed-style: any of the till guards.)
|
||||||
}
|
const u = context.user;
|
||||||
|
const anyTill = TILL_IDS.some((t) => can(u, tillGuards(t).read) || can(u, tillGuards(t).cash));
|
||||||
|
if (!anyTill && !can(u, "drawer:review")) throw redirect({ to: "/" });
|
||||||
},
|
},
|
||||||
component: function DrawerRoute() {
|
component: function DrawerRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
return (
|
return (
|
||||||
<DrawerManager
|
<DrawerManager user={user} canReview={can(user, "drawer:review")} />
|
||||||
canCreate={can(user, "drawer:create")}
|
|
||||||
canReview={can(user, "drawer:review")}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -898,7 +741,9 @@ const profileRoute = createRoute({
|
|||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
boothRoute,
|
boothRoute,
|
||||||
validateRoute,
|
// Venue-module routes (e.g. /validate) — each module gates its own routes on
|
||||||
|
// moduleOn + permission. See modules/index.ts.
|
||||||
|
...WEB_MODULES.flatMap((m) => m.routes(rootRoute)),
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
@@ -917,6 +762,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
recycleBinRoute,
|
recycleBinRoute,
|
||||||
logsRoute,
|
logsRoute,
|
||||||
backupRoute,
|
backupRoute,
|
||||||
|
...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
|
||||||
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
|
||||||
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
|
||||||
|
carwash_order: { labelKey: "booth.evtCarwashOrder", color: "text-term-cyan" },
|
||||||
|
carwash_payment: { labelKey: "booth.evtCarwashPayment", color: "text-term-cyan" },
|
||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# The Car Wash REVIEW COLLECTOR — deployed on the REVIEWER's host (art-docker-station), NOT
|
||||||
|
# on a booth. Its own Komodo stack ("wash-collector" in komodo/resources.toml) points at this
|
||||||
|
# file alone, so nothing here reaches a booth and nothing of the booth stack reaches this
|
||||||
|
# host. See wiki/concepts/vision-review-outbox.md.
|
||||||
|
#
|
||||||
|
# Reachability: booths POST to /ingest over the Netbird overlay only. Bind the published
|
||||||
|
# port to the host's OVERLAY address (COLLECTOR_BIND), never 0.0.0.0 on a host that also
|
||||||
|
# has a public interface. The Netbird policy should allow booths → this host:8090 and
|
||||||
|
# nothing else on it.
|
||||||
|
|
||||||
|
services:
|
||||||
|
collector:
|
||||||
|
image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-collector:${TAG:-dev}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${COLLECTOR_BIND:-127.0.0.1}:8090:8090"
|
||||||
|
environment:
|
||||||
|
# "<boothId>:<token>" pairs — one per booth, the booth's CARWASH_REVIEW_TOKEN under its
|
||||||
|
# pseudonymous CARWASH_REVIEW_BOOTH_ID. A Komodo secret reference in the stack env.
|
||||||
|
COLLECTOR_BOOTH_TOKENS: ${COLLECTOR_BOOTH_TOKENS:?set COLLECTOR_BOOTH_TOKENS in the stack env}
|
||||||
|
# The single reviewer login (HTTP Basic over the overlay).
|
||||||
|
COLLECTOR_REVIEWER_USER: ${COLLECTOR_REVIEWER_USER:-reviewer}
|
||||||
|
COLLECTOR_REVIEWER_PASS: ${COLLECTOR_REVIEWER_PASS:?set COLLECTOR_REVIEWER_PASS in the stack env}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
volumes:
|
||||||
|
- collector-data:/data
|
||||||
|
|
||||||
|
# Phase B trainer — a one-off job on this host's GPU, NOT a service (profile "train": it
|
||||||
|
# only runs when asked: `docker compose --profile train run --rm trainer`). Reads the
|
||||||
|
# collector's export + crops straight off the same volume; writes the ONNX classifier the
|
||||||
|
# vision image then bakes in. The image/script are the next increment; this is the seam.
|
||||||
|
# trainer:
|
||||||
|
# image: ${REGISTRY:-git.infra.msai.al/mca/parking_solution}/parking-trainer:${TAG:-dev}
|
||||||
|
# profiles: ["train"]
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# reservations:
|
||||||
|
# devices:
|
||||||
|
# - driver: nvidia
|
||||||
|
# count: all
|
||||||
|
# capabilities: [gpu]
|
||||||
|
# volumes:
|
||||||
|
# - collector-data:/data:ro
|
||||||
|
# - ./models:/out
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
collector-data:
|
||||||
@@ -28,6 +28,12 @@ services:
|
|||||||
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
|
# off (the in-UI target + retention do nothing without it). Per-booth + unique; escrow it
|
||||||
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
|
# offsite. See apps/server/.env.example + wiki/concepts/backup-recovery.md.
|
||||||
BACKUP_KEY: ${BACKUP_KEY:-}
|
BACKUP_KEY: ${BACKUP_KEY:-}
|
||||||
|
# Car Wash review outbox: collector URL + per-booth token + pseudonymous booth id, all
|
||||||
|
# three or off. See apps/server/.env.example + wiki/concepts/vision-review-outbox.md.
|
||||||
|
CARWASH_REVIEW_URL: ${CARWASH_REVIEW_URL:-}
|
||||||
|
CARWASH_REVIEW_TOKEN: ${CARWASH_REVIEW_TOKEN:-}
|
||||||
|
CARWASH_REVIEW_BOOTH_ID: ${CARWASH_REVIEW_BOOTH_ID:-}
|
||||||
|
CARWASH_REVIEW_ENTRY_SAMPLE: ${CARWASH_REVIEW_ENTRY_SAMPLE:-0}
|
||||||
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
|
||||||
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
|
||||||
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
|
||||||
@@ -35,6 +41,12 @@ services:
|
|||||||
# The booth WS live feed checks the browser Origin — must list the address operators
|
# The booth WS live feed checks the browser Origin — must list the address operators
|
||||||
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
|
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
|
||||||
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
|
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
|
||||||
|
# Venue modules this site is ENTITLED to (vendor decision, per stack in Komodo; the site
|
||||||
|
# admin activates within this set in Setup → Site). Only variables listed HERE reach the
|
||||||
|
# container — a value in the Komodo stack env alone does nothing (found 2026-09-06: every
|
||||||
|
# booth had Car Wash on). Default = what booths had before modules existed; the server
|
||||||
|
# treats a BLANK value as "every module", so never set it to "" on a booth.
|
||||||
|
MODULES_ENTITLED: ${MODULES_ENTITLED:-parking,validation}
|
||||||
volumes:
|
volumes:
|
||||||
- parking-data:/data
|
- parking-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -49,6 +61,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Engine: stub (no models) by default; prod override sets fast_alpr.
|
# Engine: stub (no models) by default; prod override sets fast_alpr.
|
||||||
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
|
VISION_RECOGNIZER: ${VISION_RECOGNIZER:-stub}
|
||||||
|
# Vehicle stage (Car Wash category suggestion): the image bakes YOLOX-S at this path.
|
||||||
|
# Set the var to an EMPTY value in the stack env to switch the stage off.
|
||||||
|
VISION_VEHICLE_MODEL_PATH: ${VISION_VEHICLE_MODEL_PATH-/app/models/yolox_s.onnx}
|
||||||
networks:
|
networks:
|
||||||
- parking
|
- parking
|
||||||
|
|
||||||
|
|||||||
+55
-2
@@ -49,8 +49,11 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-5c6a21e
|
TAG=stage-8fa66c9
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
|
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
|
||||||
|
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
|
||||||
|
MODULES_ENTITLED=parking,validation
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
@@ -82,8 +85,21 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-5c6a21e
|
TAG=stage-0411b71
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
|
# Venue modules this site is ENTITLED to (vendor decision; the site admin activates within
|
||||||
|
# this set in Setup → Site). Unset = every registered module. See wiki/decisions/venue-modules.md.
|
||||||
|
# park-2 pilots the Car Wash module (2026-09-05).
|
||||||
|
MODULES_ENTITLED=parking,carwash
|
||||||
|
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md): the collector's ingest URL
|
||||||
|
# on the Netbird overlay, this booth's pseudonymous id, and its token — the SAME secret the
|
||||||
|
# wash-collector stack lists under that id. Leave all three unset to keep the outbox off.
|
||||||
|
CARWASH_REVIEW_URL=http://docker-station.nb.infra:8090/ingest
|
||||||
|
CARWASH_REVIEW_BOOTH_ID=booth-2
|
||||||
|
CARWASH_REVIEW_TOKEN=[[wash_review_token_booth_2]]
|
||||||
|
# Also send ENTRY reads as training material (gate view, no wash): 1 = every entry (storage
|
||||||
|
# and bandwidth are not the limit; review what you have time for). N = one in N. 0 = off.
|
||||||
|
CARWASH_REVIEW_ENTRY_SAMPLE=1
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||||
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||||
@@ -93,3 +109,40 @@ JWT_SECRET=[[park_2_jwt_secret]]
|
|||||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||||
BACKUP_KEY=[[park_2_backup_key]]
|
BACKUP_KEY=[[park_2_backup_key]]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station),
|
||||||
|
# NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name
|
||||||
|
# ONLY docker-compose.collector.yml, so nothing booth-side lands here and nothing here
|
||||||
|
# lands on a booth. Booths reach it over the Netbird overlay only (bind to the overlay
|
||||||
|
# address). Its own secrets. See wiki/concepts/vision-review-outbox.md.
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
[[stack]]
|
||||||
|
name = "wash-collector"
|
||||||
|
[stack.config]
|
||||||
|
server = "art-docker-station"
|
||||||
|
git_provider = "git.infra.msai.al"
|
||||||
|
git_account = "komodo"
|
||||||
|
repo = "mca/parking_solution"
|
||||||
|
branch = "stage"
|
||||||
|
file_paths = [
|
||||||
|
"docker-compose.collector.yml"
|
||||||
|
]
|
||||||
|
registry_provider = "git.infra.msai.al"
|
||||||
|
registry_account = "komodo"
|
||||||
|
environment = """
|
||||||
|
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||||
|
# Pinned like the booths: bump to the stage-<sha> that carries the collector.
|
||||||
|
TAG=stage-0411b71
|
||||||
|
# The host's NETBIRD address (an IP: Docker port bindings take no hostname) — the ingest port
|
||||||
|
# is published on the overlay only. Booths reach it by its Netbird DNS name.
|
||||||
|
COLLECTOR_BIND=100.75.184.156
|
||||||
|
# "<boothId>:<token>" pairs, one per booth. ONE secret per booth, referenced here AND in
|
||||||
|
# that booth's own stack as its CARWASH_REVIEW_TOKEN — one value, two consumers, nothing
|
||||||
|
# to keep in sync, and rotating a booth touches one secret. The booth id is the booth's
|
||||||
|
# pseudonymous CARWASH_REVIEW_BOOTH_ID, never a site name. Add a pair per booth.
|
||||||
|
COLLECTOR_BOOTH_TOKENS=booth-2:[[wash_review_token_booth_2]]
|
||||||
|
COLLECTOR_REVIEWER_USER=reviewer
|
||||||
|
COLLECTOR_REVIEWER_PASS=[[wash_collector_reviewer_pass]]
|
||||||
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Venue modules: which optional modules the site admin has ACTIVATED (JSON array of
|
||||||
|
-- module ids, e.g. ["parking","validation"]). null = never set → everything the site is
|
||||||
|
-- entitled to (MODULES_ENTITLED env). Effective set = entitled ∩ activated, computed server-
|
||||||
|
-- side (apps/server/src/modules.ts); each change signs a config_change. Additive, nullable:
|
||||||
|
-- existing deployments see no behaviour change. See wiki/decisions/venue-modules.md.
|
||||||
|
ALTER TABLE `site_config` ADD `modules_json` text;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Car Wash module (wiki/decisions/venue-modules.md): admin master data (categories,
|
||||||
|
-- services, the category × service price matrix) + the order rows that are the wash
|
||||||
|
-- desk's queue. Orders freeze names + price at intake; their life is signed onto the
|
||||||
|
-- ledger (carwash_order / carwash_payment). Additive; tables exist whether or not the
|
||||||
|
-- module is entitled/activated at a site (modules are always migrated).
|
||||||
|
CREATE TABLE `carwash_categories` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE TABLE `carwash_services` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`active` integer DEFAULT true NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
`deleted_at` text,
|
||||||
|
`deleted_by` text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE TABLE `carwash_prices` (
|
||||||
|
`category_id` text NOT NULL,
|
||||||
|
`service_id` text NOT NULL,
|
||||||
|
`price_minor` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`category_id`, `service_id`),
|
||||||
|
FOREIGN KEY (`category_id`) REFERENCES `carwash_categories`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`service_id`) REFERENCES `carwash_services`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE TABLE `carwash_orders` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`identity` text NOT NULL,
|
||||||
|
`plate` text,
|
||||||
|
`category_id` text NOT NULL,
|
||||||
|
`category_name` text NOT NULL,
|
||||||
|
`service_id` text NOT NULL,
|
||||||
|
`service_name` text NOT NULL,
|
||||||
|
`price_minor` integer NOT NULL,
|
||||||
|
`currency` text NOT NULL,
|
||||||
|
`pay_at` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'open' NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`created_by` text NOT NULL,
|
||||||
|
`done_at` text,
|
||||||
|
`done_by` text,
|
||||||
|
`paid_at` text,
|
||||||
|
`paid_by` text,
|
||||||
|
`tender` text,
|
||||||
|
`payment_event_id` text,
|
||||||
|
`validation_event_id` text,
|
||||||
|
`void_at` text,
|
||||||
|
`void_by` text,
|
||||||
|
`void_reason` text
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE INDEX `carwash_orders_identity_idx` ON `carwash_orders` (`identity`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `carwash_orders_status_idx` ON `carwash_orders` (`status`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Car Wash module: site-level settings singleton. `pay_at` decides WHERE wash money is
|
||||||
|
-- taken at this site (booth = on the parking ticket; bay = the wash operator's own till)
|
||||||
|
-- — a Setup → Car wash choice, no longer a per-order radio on the desk (user, 2026-09-05).
|
||||||
|
CREATE TABLE `carwash_config` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`pay_at` text DEFAULT 'booth' NOT NULL,
|
||||||
|
`updated_at` text,
|
||||||
|
`updated_by` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `carwash_config` (`id`, `pay_at`) VALUES (1, 'booth');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Roles remember the manifest JOBS they were composed from (venue-modules.md §"Permissions
|
||||||
|
-- matrix", move 2) so a role built from a job chip can be flagged and re-applied when a
|
||||||
|
-- later release grows that job's bundle. The permission grid stays the enforcement layer.
|
||||||
|
CREATE TABLE `role_jobs` (
|
||||||
|
`role_id` text NOT NULL,
|
||||||
|
`job_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `role_jobs_role_id_job_id_unique` ON `role_jobs` (`role_id`,`job_id`);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Car Wash review outbox (wiki/concepts/vision-review-outbox.md): plate-blurred vehicle
|
||||||
|
-- crops + the operator's category choice, queued for a trusted remote reviewer and drained
|
||||||
|
-- one-way over the private overlay. The image is cleared once delivered.
|
||||||
|
CREATE TABLE `carwash_review_outbox` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`order_id` text NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'queued' NOT NULL,
|
||||||
|
`attempts` integer DEFAULT 0 NOT NULL,
|
||||||
|
`next_attempt_at` text,
|
||||||
|
`last_error` text,
|
||||||
|
`sent_at` text,
|
||||||
|
`image` blob,
|
||||||
|
`payload` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `carwash_review_outbox_status_idx` ON `carwash_review_outbox` (`status`,`next_attempt_at`);
|
||||||
@@ -183,6 +183,48 @@
|
|||||||
"when": 1788078414270,
|
"when": 1788078414270,
|
||||||
"tag": "0025_backup_last_status",
|
"tag": "0025_backup_last_status",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788596918862,
|
||||||
|
"tag": "0026_site_modules",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788599998177,
|
||||||
|
"tag": "0027_carwash",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788605000000,
|
||||||
|
"tag": "0028_carwash_config",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788690000000,
|
||||||
|
"tag": "0029_role_jobs",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788700000000,
|
||||||
|
"tag": "0030_carwash_vision",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788710000000,
|
||||||
|
"tag": "0031_carwash_review_outbox",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+157
-2
@@ -1,5 +1,5 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { blob, integer, 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):
|
||||||
@@ -53,6 +53,24 @@ export const rolePermissions = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The JOBS a role follows (venue-modules.md §"Permissions matrix", move 2): the
|
||||||
|
* manifest job presets the admin composed it from. Remembered so a later release that
|
||||||
|
* grows a job's bundle can be surfaced ("this role is behind the Wash operator job")
|
||||||
|
* and re-applied with one click — never expanded silently at runtime: what a role may
|
||||||
|
* do is always the explicit `role_permissions` grid. */
|
||||||
|
export const roleJobs = sqliteTable(
|
||||||
|
"role_jobs",
|
||||||
|
{
|
||||||
|
roleId: text("role_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => roles.id),
|
||||||
|
jobId: text("job_id").notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uniq: unique().on(t.roleId, t.jobId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable("users", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
@@ -233,6 +251,12 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
* own price and may differ. null = no site default set. See
|
* own price and may differ. null = no site default set. See
|
||||||
* wiki/entities/subscription.md. */
|
* wiki/entities/subscription.md. */
|
||||||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||||
|
/** Venue modules the site admin has ACTIVATED (JSON array of ModuleId, e.g.
|
||||||
|
* ["parking","validation"]). null = never set → everything the site is entitled to.
|
||||||
|
* The effective set is entitled (MODULES_ENTITLED env) ∩ this, computed server-side
|
||||||
|
* (apps/server/src/modules.ts); each change signs a config_change. Disabling a module
|
||||||
|
* never deletes anything. See wiki/decisions/venue-modules.md. */
|
||||||
|
modulesJson: text("modules_json"),
|
||||||
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
|
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
|
||||||
* (by quantity) even when they're not parked — so transients see "full" sooner and
|
* (by quantity) even when they're not parked — so transients see "full" sooner and
|
||||||
* the subscriber's spot is held. When OFF (default), only cars physically inside
|
* the subscriber's spot is held. When OFF (default), only cars physically inside
|
||||||
@@ -485,7 +509,7 @@ export const validationPrograms = sqliteTable("validation_programs", {
|
|||||||
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
// How the program discounts — see @parking/shared ValidationMode.
|
// How the program discounts — see @parking/shared ValidationMode.
|
||||||
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
|
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("comp"),
|
.default("comp"),
|
||||||
// timeCredit: the free minutes.
|
// timeCredit: the free minutes.
|
||||||
@@ -615,3 +639,134 @@ export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
|
|||||||
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||||
|
|
||||||
|
// --- Car Wash module (wiki/decisions/venue-modules.md) ----------------------
|
||||||
|
// Admin-maintained master data (categories, services, the price matrix) + the order
|
||||||
|
// rows that ARE the wash desk's queue. Master data is plainly mutable; every order
|
||||||
|
// freezes the category/service NAMES and the price at intake, and the order's life
|
||||||
|
// (created / done / void, and a bay payment) is signed onto the ledger — so history
|
||||||
|
// never depends on these rows. Soft-delete on the master data (recycle-bin pattern).
|
||||||
|
|
||||||
|
export const carwashCategories = sqliteTable("carwash_categories", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
/** Display name, e.g. "Car", "SUV", "Van", "Truck". */
|
||||||
|
name: text("name").notNull(),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
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")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const carwashServices = sqliteTable("carwash_services", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
/** Display name, e.g. "Standard", "Outside", "Inside", "Details". */
|
||||||
|
name: text("name").notNull(),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
deletedAt: text("deleted_at"),
|
||||||
|
deletedBy: text("deleted_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The price matrix: one row per (category, service) the admin priced. A missing pair
|
||||||
|
* is simply not sellable. Minor units. */
|
||||||
|
export const carwashPrices = sqliteTable(
|
||||||
|
"carwash_prices",
|
||||||
|
{
|
||||||
|
categoryId: text("category_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => carwashCategories.id),
|
||||||
|
serviceId: text("service_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => carwashServices.id),
|
||||||
|
priceMinor: integer("price_minor").notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.categoryId, t.serviceId] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const carwashOrders = sqliteTable("carwash_orders", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
/** The parking ticket id = the customer identity (the wash sits inside the park). */
|
||||||
|
identity: text("identity").notNull(),
|
||||||
|
plate: text("plate"),
|
||||||
|
categoryId: text("category_id").notNull(),
|
||||||
|
/** Frozen at intake (renames never rewrite an order). */
|
||||||
|
categoryName: text("category_name").notNull(),
|
||||||
|
serviceId: text("service_id").notNull(),
|
||||||
|
serviceName: text("service_name").notNull(),
|
||||||
|
priceMinor: integer("price_minor").notNull(),
|
||||||
|
currency: text("currency").notNull(),
|
||||||
|
/** "booth" | "bay" — see @parking/shared CarWashPayAt. */
|
||||||
|
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull(),
|
||||||
|
/** "open" | "done" | "void". Paid-ness is the separate paidAt below. */
|
||||||
|
status: text("status", { enum: ["open", "done", "void"] }).notNull().default("open"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
createdBy: text("created_by").notNull(),
|
||||||
|
doneAt: text("done_at"),
|
||||||
|
doneBy: text("done_by"),
|
||||||
|
/** Set when settled — at the bay (carwash_payment) or at the booth (the parking
|
||||||
|
* payment that carried this order as a charge line). */
|
||||||
|
paidAt: text("paid_at"),
|
||||||
|
paidBy: text("paid_by"),
|
||||||
|
tender: text("tender"),
|
||||||
|
/** Ledger event id of the payment that settled it (carwash_payment or payment). */
|
||||||
|
paymentEventId: text("payment_event_id"),
|
||||||
|
/** Ledger event id of the sponsorship validation this order applied, if any. */
|
||||||
|
validationEventId: text("validation_event_id"),
|
||||||
|
voidAt: text("void_at"),
|
||||||
|
voidBy: text("void_by"),
|
||||||
|
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
|
||||||
|
* site — "booth" (on the parking ticket) or "bay" (the wash operator's own till). */
|
||||||
|
export const carwashConfig = sqliteTable("carwash_config", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
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"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Car Wash REVIEW OUTBOX (wiki/concepts/vision-review-outbox.md): the operator's category
|
||||||
|
* choice is a hypothesis, not truth — each wash order with a vehicle read queues a
|
||||||
|
* plate-blurred vehicle CROP + the choice for a trusted remote reviewer, drained one-way
|
||||||
|
* over the private overlay when it is up. Never blocks the wash; nothing that names the
|
||||||
|
* site leaves the booth. The image is dropped once delivered. */
|
||||||
|
export const carwashReviewOutbox = sqliteTable("carwash_review_outbox", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
orderId: text("order_id").notNull(),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
status: text("status", { enum: ["queued", "sent", "failed"] }).notNull().default("queued"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
nextAttemptAt: text("next_attempt_at"),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
sentAt: text("sent_at"),
|
||||||
|
/** The JPEG crop (plate blurred). Null once sent. */
|
||||||
|
image: blob("image").$type<Buffer>(),
|
||||||
|
/** What the collector receives beside the image (no site name, no plate, no operator name). */
|
||||||
|
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
|
||||||
|
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
|
||||||
|
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
|
||||||
|
export type CarwashOrderRow = typeof carwashOrders.$inferSelect;
|
||||||
|
|||||||
@@ -772,7 +772,7 @@ export function transportLabel(t: Transport): string {
|
|||||||
// --- shared driver config fields ----------------------------------------------
|
// --- shared driver config fields ----------------------------------------------
|
||||||
// Role + failover are identical across ESC/POS printers; defined here so each
|
// Role + failover are identical across ESC/POS printers; defined here so each
|
||||||
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
// driver shares them. See wiki/concepts/printer-roles-failover.md.
|
||||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
||||||
|
|
||||||
// --- shared printer config fields (transport) ---------------------------------
|
// --- shared printer config fields (transport) ---------------------------------
|
||||||
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
// TCP-or-USB is offered identically across the ESC/POS drivers; defined here so each
|
||||||
|
|||||||
@@ -128,8 +128,9 @@ const roleField: ConfigField = {
|
|||||||
label: "Entry dispenser (outside / at the lane)",
|
label: "Entry dispenser (outside / at the lane)",
|
||||||
},
|
},
|
||||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||||
|
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
||||||
],
|
],
|
||||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rankField: ConfigField = {
|
const rankField: ConfigField = {
|
||||||
|
|||||||
@@ -262,8 +262,9 @@ class RongtaPrinter implements PrinterDevice, MonitorableDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Type guard: does this device carry a printer role (entry vs. booth)? */
|
/** Where a printer sits: at the lane (entry tickets), in the booth (receipts, reports,
|
||||||
export type PrinterRole = "entry-dispenser" | "booth-receipt";
|
* the backup for entry tickets) or at the wash desk (the Car Wash till's slips). */
|
||||||
|
export type PrinterRole = "entry-dispenser" | "booth-receipt" | "wash-desk";
|
||||||
|
|
||||||
const roleField: ConfigField = {
|
const roleField: ConfigField = {
|
||||||
key: "role",
|
key: "role",
|
||||||
@@ -277,8 +278,9 @@ const roleField: ConfigField = {
|
|||||||
label: "Entry dispenser (outside / at the lane)",
|
label: "Entry dispenser (outside / at the lane)",
|
||||||
},
|
},
|
||||||
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
|
||||||
|
{ value: "wash-desk", label: "Wash desk printer (Car Wash till slips)" },
|
||||||
],
|
],
|
||||||
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
|
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline. The wash desk's Z-reports and vouchers print on the wash desk printer, falling back to the booth printer.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rankField: ConfigField = {
|
const rankField: ConfigField = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user