Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66521da82 | ||
|
|
0571a40649 | ||
|
|
4227fe4f58 | ||
|
|
c790ec99cb | ||
|
|
fd390df423 | ||
|
|
e5d8466266 | ||
|
|
0da5251524 | ||
|
|
2afceea5e4 | ||
|
|
cf4d8e3649 | ||
|
|
aaa1630535 | ||
|
|
167bf49b6e | ||
|
|
fd0c55b13b | ||
|
|
05628936ca | ||
|
|
b341436e77 | ||
|
|
4049c0c6b1 | ||
|
|
e39c0a1dd6 | ||
|
|
bbdb4a9ae8 | ||
|
|
e0c107a94a | ||
|
|
59cb4c062e | ||
|
|
b79d485ef1 | ||
|
|
c0995b762e | ||
|
|
5f0eaa59b0 | ||
|
|
09173a4740 | ||
|
|
9468cc3e21 | ||
|
|
f0d641198c | ||
|
|
77208b857a | ||
|
|
e427313bce | ||
|
|
d17af1c52a | ||
|
|
94cfb7bcbb | ||
|
|
37a362c4a0 | ||
|
|
1fbb20b1ab | ||
|
|
f7805579f7 | ||
|
|
de0bdea84f | ||
|
|
284b3c7394 | ||
|
|
2e4fb43f09 | ||
|
|
cda2c32bd0 | ||
|
|
7851e807fa | ||
|
|
a7ac5ed9d0 | ||
|
|
1e407754e6 | ||
|
|
1fdd2b3ccf | ||
|
|
ac4b1636fe | ||
|
|
e6539d17b6 | ||
|
|
6deb95524d | ||
|
|
18344bc8b7 | ||
|
|
18a765d623 | ||
|
|
845db14e17 | ||
|
|
337f8716bb | ||
|
|
257a4cf6c0 | ||
|
|
4a268c7351 | ||
|
|
294a8176d1 | ||
|
|
a5024f7190 | ||
|
|
38f654b97a | ||
|
|
f04a81f6c7 | ||
|
|
07d5605812 | ||
|
|
65835ea40a | ||
|
|
5fa4fe0811 | ||
|
|
cf9a889547 | ||
|
|
ddc3a98e4b | ||
|
|
6b864596e0 | ||
|
|
14c2ad3ee8 | ||
|
|
9fff84dc68 | ||
|
|
c63903cc94 | ||
|
|
ac427f4427 | ||
|
|
728e380d63 | ||
|
|
8989ffb23c | ||
|
|
7c94eb9e87 | ||
|
|
e0c7ed6011 | ||
|
|
5b2827eb7e | ||
|
|
8a3a43d4ac | ||
|
|
ff118dd66d | ||
|
|
824048b9d4 | ||
|
|
c981aad2a3 | ||
|
|
e115031a57 | ||
|
|
ec8f809497 | ||
|
|
4a0a4d1cb4 | ||
|
|
1867828a9d | ||
|
|
4437b8792a | ||
|
|
4bc3e33953 | ||
|
|
477b5e7ce9 | ||
|
|
6e227a214a | ||
|
|
9bd6bea759 | ||
|
|
7e34f55005 | ||
|
|
f5212959b4 | ||
|
|
62ac9f825c | ||
|
|
bdc58f396e | ||
|
|
e1f0ad8431 | ||
|
|
760f3b6ee2 | ||
|
|
ffc88e33b4 | ||
|
|
56a65b2364 | ||
|
|
063a8f9a2d | ||
|
|
938a739dfe |
@@ -8,8 +8,18 @@ POSTGRES_DB=mobilityops
|
|||||||
POSTGRES_USER=mobilityops
|
POSTGRES_USER=mobilityops
|
||||||
POSTGRES_PASSWORD=mobilityops
|
POSTGRES_PASSWORD=mobilityops
|
||||||
APP_SECRET=replace-in-production
|
APP_SECRET=replace-in-production
|
||||||
DEMO_TODAY=2026-08-01
|
|
||||||
TZ=Europe/Brussels
|
TZ=Europe/Brussels
|
||||||
|
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the
|
||||||
|
# current Unraid review environment); set true only once MobilityOps is served over HTTPS,
|
||||||
|
# otherwise browsers will silently drop the cookie and no one can log in.
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
||||||
|
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
||||||
|
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
||||||
|
DEMO_ORGANIZATION_NAME=Northstar Mobility
|
||||||
|
DEMO_TIMEZONE=Europe/Brussels
|
||||||
|
DEMO_ALLOW_RESET=true
|
||||||
|
|
||||||
# n8n
|
# n8n
|
||||||
N8N_BASE_URL=http://n8n:5678
|
N8N_BASE_URL=http://n8n:5678
|
||||||
@@ -19,6 +29,11 @@ N8N_BASIC_AUTH_ACTIVE=true
|
|||||||
N8N_BASIC_AUTH_USER=admin
|
N8N_BASIC_AUTH_USER=admin
|
||||||
N8N_BASIC_AUTH_PASSWORD=change-me
|
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||||
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
||||||
|
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
|
||||||
|
# processing webhook, so the webhook trigger can require Header Auth instead of being
|
||||||
|
# publicly callable by anyone who discovers the URL. Must match the value stored in
|
||||||
|
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
|
||||||
|
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
|
||||||
|
|
||||||
# RAGcore integration
|
# RAGcore integration
|
||||||
KNOWLEDGE_PROVIDER=demo
|
KNOWLEDGE_PROVIDER=demo
|
||||||
@@ -27,6 +42,8 @@ RAGCORE_TENANT=northstar-mobility-demo
|
|||||||
RAGCORE_WORKSPACE=mobilityops
|
RAGCORE_WORKSPACE=mobilityops
|
||||||
RAGCORE_COLLECTION=internal-procedures
|
RAGCORE_COLLECTION=internal-procedures
|
||||||
RAGCORE_API_TOKEN=
|
RAGCORE_API_TOKEN=
|
||||||
|
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||||
|
RAGCORE_SPACE_ID=
|
||||||
|
|
||||||
# ITWorx MCP Hub integration
|
# ITWorx MCP Hub integration
|
||||||
MCP_HUB_REGISTRATION_ENABLED=false
|
MCP_HUB_REGISTRATION_ENABLED=false
|
||||||
|
|||||||
@@ -60,7 +60,10 @@
|
|||||||
- `knowledge/procedures/09-booking-conflicts.md`
|
- `knowledge/procedures/09-booking-conflicts.md`
|
||||||
- `knowledge/procedures/10-roles-and-escalation.md`
|
- `knowledge/procedures/10-roles-and-escalation.md`
|
||||||
- `n8n/README.md`
|
- `n8n/README.md`
|
||||||
- `n8n/mobilityops-return-processing.json`
|
- `n8n/workflows/MANIFEST.md`
|
||||||
|
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||||
|
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||||
|
- `n8n/workflows/check_drift.py`
|
||||||
- `seed/README.md`
|
- `seed/README.md`
|
||||||
- `seed/bookings.csv`
|
- `seed/bookings.csv`
|
||||||
- `seed/customers.csv`
|
- `seed/customers.csv`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: up down logs test lint seed reset n8n-setup demo e2e
|
.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
@@ -26,12 +26,22 @@ reset:
|
|||||||
# One-time per environment: imports and activates the n8n return-processing workflow.
|
# One-time per environment: imports and activates the n8n return-processing workflow.
|
||||||
# The n8n owner account itself cannot be scripted safely and must be created once at
|
# The n8n owner account itself cannot be scripted safely and must be created once at
|
||||||
# http://localhost:5678/setup (any email/password, no verification required) before
|
# http://localhost:5678/setup (any email/password, no verification required) before
|
||||||
# this target's activation takes effect. See docs/17-runbook.md.
|
# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
|
||||||
|
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
|
||||||
|
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
|
||||||
n8n-setup:
|
n8n-setup:
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
|
|
||||||
|
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
||||||
|
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
|
||||||
|
# needs "Fleet Ops Service Token").
|
||||||
|
n8n-setup-scan:
|
||||||
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
|
||||||
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
|
docker compose restart n8n
|
||||||
|
|
||||||
# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed.
|
# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed.
|
||||||
demo: up
|
demo: up
|
||||||
docker compose exec api python -m app.cli seed --reset
|
docker compose exec api python -m app.cli seed --reset
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
# MobilityOps
|
# Fleet Ops
|
||||||
|
|
||||||
**Connected operations for vehicle rental and service teams.**
|
**Connected operations for vehicle rental and service teams.**
|
||||||
|
|
||||||
MobilityOps is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub.
|
Fleet Ops is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub.
|
||||||
|
|
||||||
|
**Naming:** "Fleet Ops" is the product's visible name everywhere in the UI, the demo
|
||||||
|
knowledge base, and this documentation. "MobilityOps" remains the technical
|
||||||
|
identifier only — the repository name, local directory, package/module names, Docker
|
||||||
|
Compose project, deployment directory, and database names. The UI is fully trilingual
|
||||||
|
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
|
||||||
|
architecture, the vehicle-status decision table, and the correction evidence, and
|
||||||
|
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
|
||||||
|
NL/FR translation gaps, centralized API-error localization, the time-dependent
|
||||||
|
Europe/Brussels dashboard greeting).
|
||||||
|
|
||||||
The web application uses the premium responsive **Control Rail** interface: a compact
|
The web application uses the premium responsive **Control Rail** interface: a compact
|
||||||
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
||||||
@@ -12,18 +22,36 @@ design decision and visual evidence.
|
|||||||
|
|
||||||
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real.
|
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van
|
||||||
|
rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app
|
||||||
|
guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page
|
||||||
|
all make the fictional context, synthetic-data status, and real-vs-simulated boundaries
|
||||||
|
explicit without any verbal explanation. See `docs/demo-release/` for the full demo
|
||||||
|
concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
|
||||||
|
design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
|
||||||
|
rollback).
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
The PoC implements:
|
The PoC implements:
|
||||||
|
|
||||||
- operations dashboard;
|
- operations dashboard with a truthful aggregate n8n/MCP integration-status card;
|
||||||
- vehicle and booking views;
|
- vehicle and booking views with working search, filters and pagination;
|
||||||
- one complete vehicle-return workflow;
|
- server-backed session lifecycle (refresh-safe, central 401 handling);
|
||||||
- five deterministic data-quality checks;
|
- a role matrix enforced server-side and mirrored in the UI (see
|
||||||
|
`docs/12-security-and-audit.md`);
|
||||||
|
- vehicle return capture → authoritative server-evaluated review → commit → result;
|
||||||
|
- five deterministic data-quality checks, each with a bounded resolution flow, plus a
|
||||||
|
manual scan action;
|
||||||
- human review and customer merge;
|
- human review and customer merge;
|
||||||
- audit trail;
|
- audit trail with human-readable before/after evidence and safe entity links;
|
||||||
|
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
|
||||||
|
- safe, confirmed demo reset;
|
||||||
- RAGcore-backed knowledge assistant with citations;
|
- RAGcore-backed knowledge assistant with citations;
|
||||||
- one n8n return-processing workflow;
|
- two n8n workflows: return processing, and a scheduled data-quality scan with
|
||||||
|
crash-recoverable outbox delivery leases;
|
||||||
- four read-only MCP tools through ITWorx MCP Hub;
|
- four read-only MCP tools through ITWorx MCP Hub;
|
||||||
- deterministic demo reset and five-minute showcase.
|
- deterministic demo reset and five-minute showcase.
|
||||||
|
|
||||||
@@ -33,18 +61,26 @@ It is not an ERP, CRM, accounting package, public booking site, payment system o
|
|||||||
|
|
||||||
- **n8n**: fully implemented and verified against a real n8n instance, including
|
- **n8n**: fully implemented and verified against a real n8n instance, including
|
||||||
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
|
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
|
||||||
with backoff, self-heals once n8n returns) and the failed-delivery manual-retry path.
|
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path,
|
||||||
|
stale-delivery-lease recovery after a simulated crash, and a second (scheduled
|
||||||
|
quality-scan) workflow live-verified end to end against a real n8n instance.
|
||||||
|
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox
|
||||||
|
delivery counts, not just the most recent event.
|
||||||
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
|
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
|
||||||
over the local procedure documents) is what satisfies the knowledge-assistant
|
over the local procedure documents) is what satisfies the knowledge-assistant
|
||||||
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
|
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
|
||||||
implemented and unit-tested, including its unavailable-degradation path, but was never
|
implemented and unit-tested, including its unavailable-degradation path, but was never
|
||||||
exercised against a live RAGcore instance in this environment.
|
exercised against a live RAGcore instance in this environment.
|
||||||
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
|
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
|
||||||
directly `curl`-verified with correct auth enforcement and audit logging. No live Hub
|
directly `curl`-verified with correct auth enforcement and audit logging.
|
||||||
instance was reachable in this environment to verify an actual Hub round trip.
|
`MCP_HUB_REGISTRATION_ENABLED` is now actually wired into `Settings` (it was previously
|
||||||
|
declared in `.env.example` but silently dropped) and reported honestly by the
|
||||||
|
integration-status endpoint. No live Hub instance was reachable in this environment to
|
||||||
|
verify an actual Hub round trip.
|
||||||
|
|
||||||
See `artifacts/final-acceptance/summary.md` for full verification evidence and exact
|
See `artifacts/functional-completion/final-summary.md` for the functional-completion
|
||||||
commands.
|
audit evidence (supersedes the design-validation summary below for integration status),
|
||||||
|
and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance evidence.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
@@ -60,6 +96,11 @@ commands.
|
|||||||
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
|
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
|
||||||
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
|
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
|
||||||
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
|
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
|
||||||
|
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
|
||||||
|
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
|
||||||
|
- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
|
||||||
|
tour, and runbook.
|
||||||
|
- `artifacts/demo-release/` — demo-productization acceptance evidence.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -83,9 +124,9 @@ All defaults are configurable via `.env` (see `.env.example`).
|
|||||||
## Quality gates
|
## Quality gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # backend: pytest (66 tests)
|
make test # backend: pytest (151 tests)
|
||||||
make lint # backend: ruff + mypy (strict, zero errors)
|
make lint # backend: ruff + mypy (strict, zero errors)
|
||||||
make e2e # frontend: Playwright end-to-end (18 tests, live stack required)
|
make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Demo-productization final summary
|
||||||
|
|
||||||
|
## Branches and commits
|
||||||
|
|
||||||
|
- **Gitea repository**: `ssh://git@192.168.10.150:222/Jens/MobilityOps.git` (browsable at
|
||||||
|
`http://192.168.10.150:3000/Jens/MobilityOps`)
|
||||||
|
- **Branch**: `feat/mobilityops-functional-completion` (no new branch created; no merge
|
||||||
|
to `main`; no rebase/reset/squash/force-push; full git history preserved, as required)
|
||||||
|
- **Start commit** (functional-completion baseline, already accepted):
|
||||||
|
`e0c7ed60112510687627d20a957af91c8b9db7f8`
|
||||||
|
- **Final commit**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — verified via
|
||||||
|
`git rev-parse HEAD` on `feat/mobilityops-functional-completion` and confirmed to match
|
||||||
|
`/mnt/user/appdata/mobilityops/.deploy/source-revision` on the Unraid server exactly.
|
||||||
|
(This corrects a self-reference gap in the immediately preceding pair of commits, which
|
||||||
|
necessarily could not know their own hash at the time they were written; this is now
|
||||||
|
the single, unambiguous, verified reference. The repository's primary branch is
|
||||||
|
`master`, not `main` — no branch named `main` exists in this repository.)
|
||||||
|
- **Live URL**: `http://192.168.10.150:1236`
|
||||||
|
|
||||||
|
## Demo organisation and context
|
||||||
|
|
||||||
|
**Northstar Mobility** — a fictitious Belgian camper/van rental company (~50 vehicles,
|
||||||
|
one main location, rental team, an Operations Manager, a small workshop). This name was
|
||||||
|
already a locked internal decision (`ragcore_tenant: northstar-mobility-demo`,
|
||||||
|
`PROJECT_STATE.md`'s "Locked decisions") before this work — this pass surfaces it in the
|
||||||
|
UI rather than inventing it. Full concept: `docs/demo-release/demo-concept.md`.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- **Operations Manager** — full access: data-quality resolution, workflow retries, audit
|
||||||
|
trail, demo reset, the Demo Guide.
|
||||||
|
- **Rental Employee** — scoped access: bookings, returns, fleet, knowledge assistant.
|
||||||
|
|
||||||
|
Both are reachable from the login screen with no password.
|
||||||
|
|
||||||
|
## Demo Guide
|
||||||
|
|
||||||
|
An 8-step, sessionStorage-persisted guided tour (Operations-Manager-only, since every
|
||||||
|
step requires that role). Full design: `docs/demo-release/demo-guide.md`. Steps: (1)
|
||||||
|
understand operational state, (2) open the booking needing attention, (3) process the
|
||||||
|
odometer-anomaly return, (4) handle the created data-quality issue, (5) merge the
|
||||||
|
duplicate customer, (6) ask the knowledge assistant, (7) check automation + audit, (8)
|
||||||
|
review real vs. synthetic vs. not-connected.
|
||||||
|
|
||||||
|
## Scenarios (all 5, full detail in `docs/demo-release/demo-scenarios.md`)
|
||||||
|
|
||||||
|
| # | Scenario | Fixed records | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Odometer regression on return | `BK-DEMO-RETURN` / `MO-024` | Either |
|
||||||
|
| 2 | Possible duplicate customer | `CUS-0012` / `CUS-0178` / `DQ-DEMO-DUPLICATE` | OM |
|
||||||
|
| 3 | Overlapping bookings | `MO-016` / `BK-DEMO-OVERLAP-A/B` / `DQ-DEMO-OVERLAP` | OM |
|
||||||
|
| 4 | Failed automation, retried | outbox event `...020` / `BK-H-0020` | OM |
|
||||||
|
| 5 | Grounded procedure question | (no fixed record; suggested questions) | Either |
|
||||||
|
|
||||||
|
`GET /api/v1/demo/manifest`'s `scenarios` array derives `ready`/`blocked_reason` from the
|
||||||
|
live underlying records, never hardcoded — confirmed via `backend/tests/
|
||||||
|
test_demo_manifest.py` (`test_demo_manifest_scenarios_ready_after_fresh_reset`) and
|
||||||
|
live-checked after every reset throughout this work.
|
||||||
|
|
||||||
|
## Seed strategy and date-anchoring
|
||||||
|
|
||||||
|
`seed/generate_seed.py --anchor 2026-08-01 --seed 20260801` produces deterministic CSVs
|
||||||
|
with absolute timestamps authored against a fixed anchor. `backend/app/seed_loader.py`
|
||||||
|
shifts every seeded datetime by `(real today − authored anchor)` on every seed/reset, so
|
||||||
|
"today"/"near-future"/"currently overlapping" scenarios stay true to the actual reset
|
||||||
|
moment instead of decaying. This fixed a real, confirmed bug (`BK-DEMO-RETURN` was found
|
||||||
|
sitting 2 days in the past before this fix). Full detail: `docs/demo-release/demo-data.md`.
|
||||||
|
|
||||||
|
## Reset strategy
|
||||||
|
|
||||||
|
`POST /api/v1/demo/reset` (Operations Manager only, gated by `DEMO_ALLOW_RESET`) clears
|
||||||
|
MobilityOps's own tables, reseeds with a fresh date anchor, re-runs the data-quality scan,
|
||||||
|
and runs a server-side scenario-integrity check (`scenario_integrity_report()`) recorded
|
||||||
|
in both the response and the `demo_reset` audit event. Reachable from the sidebar, the
|
||||||
|
Demo Guide, and the About page. Never touches shared n8n/RAGcore/MCP data, other
|
||||||
|
containers, or volumes.
|
||||||
|
|
||||||
|
## Real vs. synthetic vs. not-connected
|
||||||
|
|
||||||
|
See `docs/demo-release/demo-concept.md` for the full breakdown. In short: auth/roles,
|
||||||
|
vehicle/booking management, return preview/commit, the 5 data-quality rules and their
|
||||||
|
resolutions, the audit trail, n8n orchestration, Docker deployment, and the automated
|
||||||
|
test suite are all really implemented. The organisation, all people, vehicles, bookings,
|
||||||
|
procedures, and the 5 named scenarios are synthetic. RAGcore and the ITWorx MCP Hub are
|
||||||
|
not live-connected (honestly labelled "Demomodus"/"Niet gekoppeld" everywhere, never a
|
||||||
|
fabricated success).
|
||||||
|
|
||||||
|
## Test results
|
||||||
|
|
||||||
|
### Backend (clean checkout, isolated stack)
|
||||||
|
- `pytest`: **127 passed**
|
||||||
|
- `ruff check .`: clean
|
||||||
|
- `mypy app`: clean (48 source files)
|
||||||
|
|
||||||
|
### Frontend (clean checkout, isolated stack)
|
||||||
|
- `npm ci`: clean (pre-existing esbuild-moderate/react-router-RSC-high advisories,
|
||||||
|
unchanged from before this work — not introduced by it)
|
||||||
|
- `tsc -b`: clean
|
||||||
|
- `npm run build`: clean
|
||||||
|
- Full Playwright suite: **56 passed** (against the isolated clean-checkout stack)
|
||||||
|
|
||||||
|
### Guided-demo test
|
||||||
|
`frontend/e2e/guided-demo-full.spec.ts` — one comprehensive test walking a fresh
|
||||||
|
Operations Manager session through all 8 Demo Guide steps performing the real action at
|
||||||
|
each step (processes the actual odometer-anomaly return, resolves the resulting
|
||||||
|
data-quality issue, merges the duplicate customer, asks a suggested knowledge question,
|
||||||
|
checks automation + audit, reviews the About page), then resets the demo data again to
|
||||||
|
restore the environment. **Passed**, confirmed stable across repeated runs both locally
|
||||||
|
and against the live Unraid deployment.
|
||||||
|
|
||||||
|
### Clean-checkout drill
|
||||||
|
Fresh `git clone` of this branch/commit into an isolated scratch directory, `.env` from
|
||||||
|
`.env.example`, isolated Compose project name (`mobilityops-cleandrill`) and remapped
|
||||||
|
host ports (`compose.override.yaml` with `!override` merge tags — no shared state with
|
||||||
|
any other stack), `docker compose up --build -d` from empty volumes → migrations ran
|
||||||
|
automatically (`e7b08389f47f (head)`) → seeded → full backend gate (127 passed, ruff/
|
||||||
|
mypy clean) → `npm ci`/`tsc -b`/`vite build` clean → full Playwright suite (56 passed)
|
||||||
|
→ reseeded and confirmed all 5 scenarios `ready: true` via the manifest → torn down
|
||||||
|
(`docker compose down -v` on the isolated project only; the working dev stack was never
|
||||||
|
touched).
|
||||||
|
|
||||||
|
### Server deployment
|
||||||
|
Deployed incrementally after every batch (10 deploy cycles across this work); final
|
||||||
|
state: both `api` and `web` rebuilt and healthy at the final commit, `db` untouched
|
||||||
|
across all of them (no destructive migrations on this branch). Migrations at
|
||||||
|
`e7b08389f47f (head)` throughout. `.deploy/source-revision` on the server matches the
|
||||||
|
final commit exactly.
|
||||||
|
|
||||||
|
### Container health
|
||||||
|
`docker compose ps` on the server: `api`, `db`, `web` all `healthy`, no restart loops.
|
||||||
|
|
||||||
|
### Browser console / network
|
||||||
|
No unexpected console errors on login, dashboard, scenarios, About, or with the Demo
|
||||||
|
Guide open (verified via `demo-accessibility.spec.ts`; the one benign 401 from the app's
|
||||||
|
own session-probe on first load is expected and explicitly accounted for, not silenced
|
||||||
|
blindly). No unresolved server errors in `docker logs` for `api`/`web` at the time of
|
||||||
|
this evidence capture.
|
||||||
|
|
||||||
|
### Responsive / accessibility
|
||||||
|
- Demo Guide renders as a correctly-anchored bottom sheet at 390px with no horizontal
|
||||||
|
overflow (`demo-accessibility.spec.ts`).
|
||||||
|
- **Real bug found and fixed**: the Demo Guide's fixed desktop side panel overlapped
|
||||||
|
main content with no reflow, making the return form's "Review return" button
|
||||||
|
unclickable while the guide was open at ordinary desktop widths — this surfaced while
|
||||||
|
writing the full guided-demo test. Fixed via a `guide-open` layout class that reserves
|
||||||
|
space for the panel; regression-tested.
|
||||||
|
- Demo badge and Demo Guide triggers are keyboard-focusable and operable (Enter to open,
|
||||||
|
explicit close controls).
|
||||||
|
- Existing responsive-overflow checks (390/768/1280/1440px) remain green throughout.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- RAGcore and the ITWorx MCP Hub are not live-connected in this environment (by design
|
||||||
|
— see scope). The knowledge assistant uses a local, English-only demo knowledge base;
|
||||||
|
a Dutch question against it returns "insufficient evidence" (verified empirically), so
|
||||||
|
suggested questions and the Demo Guide's step 6 instructions deliberately stay in
|
||||||
|
English rather than silently breaking the demo's centerpiece grounded-answer feature.
|
||||||
|
- Existing operational screens (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||||
|
Audit, Automation internals) remain in English; only new demo-productization surfaces
|
||||||
|
(login, Demo Guide, scenario overview, About page, demo badge, plain-language
|
||||||
|
integration labels) are in Dutch — a deliberate, documented scope decision, not an
|
||||||
|
oversight (`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||||
|
- Scenario S3 ("missing inspection before next booking", `MO-031`) is seeded and visible
|
||||||
|
in the attention queue but isn't one of the 5 scenarios surfaced on `/scenarios`,
|
||||||
|
matching the brief's request for exactly 5.
|
||||||
|
|
||||||
|
## 5-minute and 10-minute demo flows
|
||||||
|
|
||||||
|
See `docs/demo-release/demo-runbook.md` for the exact click-through scripts.
|
||||||
|
|
||||||
|
## Redeploy commands and rollback procedure
|
||||||
|
|
||||||
|
See `docs/demo-release/demo-runbook.md` — `git archive` → `scp` → extract → rebuild
|
||||||
|
`api`/`web` → confirm migrations → reseed. Rollback: extract an earlier
|
||||||
|
`.deploy/source-<short-sha>.tar.gz` and update `.deploy/source-revision` to match.
|
||||||
|
|
||||||
|
## Evidence screenshots
|
||||||
|
|
||||||
|
All captured live against `http://192.168.10.150:1236` (`artifacts/demo-release/screenshots/`):
|
||||||
|
|
||||||
|
1. `01-demo-entry-desktop.png` / `02-demo-entry-mobile.png` — demo entry, both sizes
|
||||||
|
2. `03-dashboard-with-scenarios.png` — dashboard with the scenario teaser panel
|
||||||
|
3. `04-demo-guide.png` — the Demo Guide panel open
|
||||||
|
4. `05-return-preview.png` / `06-return-result.png` — the return flow
|
||||||
|
5. `07-data-quality-resolution.png` — a data-quality issue with its plain-language explainer
|
||||||
|
6. `08-duplicate-customer-merge.png` — the duplicate-customer comparison/merge UI
|
||||||
|
7. `09-knowledge-assistant.png` — a grounded answer with cited sources
|
||||||
|
8. `10-integration-status.png` — plain-language integration status on Automation
|
||||||
|
9. `11-automation-retry-before.png` / `11-automation-retry-after.png` — a workflow retry
|
||||||
|
10. `12-audit-trail.png` / `13-audit-related-events.png` — audit trail + correlation drill-down
|
||||||
|
11. `14-about-demo.png` — the About page
|
||||||
|
12. `15-demo-badge-popover.png` — the permanent synthetic-demo badge popover
|
||||||
|
13. `16-reset-confirm.png` — the reset confirmation flow
|
||||||
|
|
||||||
|
No secrets appear in any screenshot or in this document.
|
||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,284 @@
|
|||||||
|
# Fleet Ops correction and release — final evidence
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- Source branch / commit (verified pre-correction baseline): `master` @ `18344bc8b7a75a2f868bf15bf498fc030ac6c34c`
|
||||||
|
- Fix branch: `fix/fleet-ops-i18n-status-flow`
|
||||||
|
- Final fix-branch commit: `284b3c7` (merged content identical to `2e4fb43`, which carries the evidence-summary localization fix)
|
||||||
|
- Main-before-merge: `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` (confirmed unchanged via `git fetch` + `git rev-parse origin/master` immediately before merging — no unexpected commits landed on master while this branch was in progress)
|
||||||
|
- Merge commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`git merge --no-ff fix/fleet-ops-i18n-status-flow -m "merge: complete Fleet Ops localization and status resolution"`, zero conflicts)
|
||||||
|
- Final main commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
|
||||||
|
- Deployed commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`.deploy/source-revision` on Unraid)
|
||||||
|
- Gitea main branch: `master` (confirmed via `git fetch origin && git rev-parse origin/master` matching local `master` after push)
|
||||||
|
- Live URL: `http://192.168.10.150:1236`
|
||||||
|
|
||||||
|
Fix-branch commit history: `6deb955`, `e6539d1`, `ac4b163`, `1fdd2b3`, `1e40775`, `a7ac5ed`, `7851e80`, `cda2c32`, `2e4fb43`, `284b3c7`.
|
||||||
|
|
||||||
|
## What this correction fixed
|
||||||
|
|
||||||
|
1. **Status-recommendation flow redesigned** (sections 8A–8F). The old single opaque
|
||||||
|
"calculate and apply recommended status" action is replaced by a single shared, pure
|
||||||
|
evaluator (`backend/app/services/vehicle_status.py::evaluate_vehicle_status`,
|
||||||
|
documented in `docs/fleet-ops-correction/vehicle-status-decision-table.md`) used
|
||||||
|
identically by the scanner, a non-mutating preview endpoint
|
||||||
|
(`POST /api/v1/data-quality/issues/{ref}/status-recommendation`), and a
|
||||||
|
transactional apply endpoint (`POST .../apply-recommended-status`) that locks the
|
||||||
|
row, recomputes facts, rejects a stale `recommendation_token`, refuses unsafe/manual-
|
||||||
|
review recommendations, and re-validates post-write before resolving the issue.
|
||||||
|
- Forbidden shortcuts eliminated: "maintenance + active booking" no longer
|
||||||
|
auto-recommends "rented" (being in maintenance is itself now a blocking fact);
|
||||||
|
"maintenance with nothing else wrong" no longer auto-clears to "available" (no
|
||||||
|
fact proves maintenance is actually finished — release stays a manual decision).
|
||||||
|
- Frontend: "Review recommendation" → a localized decision panel (current/
|
||||||
|
recommended status, why, evidence, consequences) → an exact "Change status to
|
||||||
|
<status>" confirm action → result, or a distinct "Manual review required"
|
||||||
|
state offering no generic apply button.
|
||||||
|
2. **MO-016 order independence** (section 9). Order independence does not mean "same
|
||||||
|
final status regardless of order" — resolving the booking overlap first genuinely
|
||||||
|
removes the conflict, correctly leaving nothing to apply. What holds either way: the
|
||||||
|
recommendation always reflects real current facts (never a stale proxy), and nothing
|
||||||
|
unsafe is ever applied (never "rented"). Proven by a backend test explicitly scoped
|
||||||
|
to MO-016/DQ-DEMO-STATUS (the original version wasn't — `_first_open()` returned
|
||||||
|
whichever of ~14 open `vehicle_status_conflict` issues was most recent, not
|
||||||
|
necessarily MO-016's) and a browser-level Playwright test covering both orders.
|
||||||
|
3. **"Fleet Ops" is a non-localizable brand constant** (`frontend/src/product.ts`,
|
||||||
|
backend `PRODUCT_NAME`), wired via `{{productName}}` interpolation everywhere the
|
||||||
|
brand appeared in locale prose. A permanent test fails the build if any locale file
|
||||||
|
ever defines the brand name or an `appName` key again.
|
||||||
|
4. **Dynamic backend prose converted to message codes + params** (sections 5/6/10):
|
||||||
|
return status reasons, audit field/actor-type labels, automation `last_error` (new
|
||||||
|
`last_error_code` column, migration `799d8800e241`), search results (sections/
|
||||||
|
vehicles/bookings/issues), and — found live on Unraid — the data-quality evidence
|
||||||
|
summary. Raw technical text is demoted to a "Technical details" disclosure
|
||||||
|
everywhere.
|
||||||
|
5. **Knowledge-base fixes**: the demo provider's tokenizer silently dropped accented
|
||||||
|
characters (`[a-z0-9]+` split "véhicule" into "v"+"hicule"), breaking French
|
||||||
|
retrieval broadly — fixed to include the Latin-1 accented range. Reweighted section
|
||||||
|
scoring so a body match (real substance) outranks a heading/title match (a shallow
|
||||||
|
structural hint) — the old weighting misranked the damage procedure behind an
|
||||||
|
unrelated document for the brief's exact validation question in all 3 languages.
|
||||||
|
Removed leftover "MobilityOps"/"PoC" mentions from 9 procedure documents.
|
||||||
|
6. **Search, audit, automation, maintenance/inspections localized** (section 10):
|
||||||
|
backend returns stable codes + params only; the frontend localizes section labels,
|
||||||
|
vehicle summaries, booking/issue statuses, audit action/field/actor labels,
|
||||||
|
automation error explanations, and maintenance/inspection type labels.
|
||||||
|
7. **i18n test suite strengthened** (section 11): key parity, brand invariant,
|
||||||
|
translation-quality (cross-locale identical-value detection), a hardcoded-JSX-text
|
||||||
|
static scan (had to anchor on backreferenced closing-tag names — a naive `>text<`
|
||||||
|
regex misread TypeScript generics as JSX), and a 3-language route matrix (every main
|
||||||
|
route, no console errors, correct `html[lang]`, real page headings).
|
||||||
|
|
||||||
|
## Live-caught bug (the deployment validation earning its keep)
|
||||||
|
|
||||||
|
Live validation on the freshly-deployed fix branch directly caught a real defect: every
|
||||||
|
data-quality issue's top-of-page evidence summary was unconditionally showing raw,
|
||||||
|
always-English text (e.g. *"vehicle marked available while reserved bookings
|
||||||
|
conflict"*) in **all three languages**, because the frontend never finished the
|
||||||
|
`evidence.signals` localization the backend had already been emitting (the backend code
|
||||||
|
even had a comment describing the intended design that the frontend didn't implement).
|
||||||
|
Fixed in commit `2e4fb43`:
|
||||||
|
- `DataQualityIssueDetail.tsx` now renders `evidence.signals` through the operator's
|
||||||
|
locale as the primary evidence text.
|
||||||
|
- The four `DQ-DEMO-*` seed rows that anchor the guided demo's scripted scenarios now
|
||||||
|
carry real, accurate signals computed at seed time (the duplicate-customer similarity
|
||||||
|
score is the actual `SequenceMatcher` ratio on the seeded names, not invented).
|
||||||
|
- Rows with no structured signals fall back to raw text rather than showing a blank
|
||||||
|
summary; the one known filler placeholder gets its own localized rendering.
|
||||||
|
- A regression test locks this in: the vehicle-status-conflict evidence summary must
|
||||||
|
show localized text and must never contain the specific raw English sentence that was
|
||||||
|
live-visible before the fix, in all 3 languages.
|
||||||
|
|
||||||
|
Also found and fixed along the way: a frontend logic bug conflating "no conflict" with
|
||||||
|
"manual review required" (both carry `safe_to_apply: false`), which showed a false
|
||||||
|
"manual review required" panel for MO-016 after its booking overlap was resolved
|
||||||
|
instead of the correct "no change needed" state (fixed in `1fdd2b3`).
|
||||||
|
|
||||||
|
## Translation coverage
|
||||||
|
|
||||||
|
- All three locale files (`nl-BE`, `en-GB`, `fr-BE`) define exactly the same key set
|
||||||
|
for every namespace (`i18n-coverage.spec.ts`, structural guarantee).
|
||||||
|
- No locale file contains an empty string value.
|
||||||
|
- No locale file defines the brand name or an `appName` key (brand-invariant test).
|
||||||
|
- Cross-locale translation-quality check: for every string ≥8 characters of real prose,
|
||||||
|
nl-BE ≠ en-GB, fr-BE ≠ en-GB, fr-BE ≠ nl-BE, with a precise, audited allowlist for
|
||||||
|
genuine proper nouns/cognates (23 entries, each with a documented reason).
|
||||||
|
- Hardcoded-JSX-text static scan: zero findings against the current codebase (verified
|
||||||
|
against both false positives — TypeScript generics — and a deliberately-injected-
|
||||||
|
then-reverted false negative).
|
||||||
|
- 3-language route matrix: every main route (dashboard, vehicles, vehicle detail,
|
||||||
|
bookings, booking detail, data quality, issue detail, automation, knowledge, audit,
|
||||||
|
scenarios, about) opens cleanly in all 3 languages with no console errors, correct
|
||||||
|
`html[lang]`, and a real page heading.
|
||||||
|
- **Remaining visible wrong-language text**: none found. The one gap that existed (the
|
||||||
|
data-quality evidence summary) was found live and fixed before merge.
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
- Visible product name: **Fleet Ops**, exactly, in all 3 languages, everywhere (login,
|
||||||
|
topbar, footer "Fleet Ops Demo", document title, About page, Demo Guide, knowledge
|
||||||
|
base). Verified structurally (brand-invariant test) and live (branding test across
|
||||||
|
dashboard/vehicles/data-quality/audit/automation/knowledge pages in all 3 languages;
|
||||||
|
visual screenshots of the login screen in nl-BE and fr-BE).
|
||||||
|
- Technical identifier retained (by design, per the brief): repository name, local
|
||||||
|
directory, package/module names, Compose project, deployment directory, database
|
||||||
|
name, and the `/health` endpoint's `service: "mobilityops-api"` field remain
|
||||||
|
"mobilityops" — none of these are visible UI text.
|
||||||
|
- No visible "MobilityOps" or "PoC" anywhere in the UI or the demo knowledge base
|
||||||
|
(9 procedure documents cleaned up; regression test in `test_knowledge.py` scans every
|
||||||
|
procedure file for both strings).
|
||||||
|
|
||||||
|
## Status-preview / apply / manual-review / MO-016 ordering
|
||||||
|
|
||||||
|
- **Preview**: verified non-mutating — the issue's `status` stays `"open"` after
|
||||||
|
calling the preview endpoint and re-fetching it via a fresh request.
|
||||||
|
- **Apply**: the confirm button names the exact target status ("Change status to
|
||||||
|
Blocked" / "Status wijzigen naar Geblokkeerd" / "Changer le statut vers Bloqué");
|
||||||
|
applying resolves the issue and updates the vehicle atomically.
|
||||||
|
- **Manual review**: MO-024 (active rental + service-threshold reached, a genuine fact
|
||||||
|
contradiction) shows "Manual review required" with no generic apply button rendered
|
||||||
|
at all.
|
||||||
|
- **Stale token**: simulated by resolving the underlying booking overlap after the
|
||||||
|
preview was fetched but before applying — the apply call is correctly rejected
|
||||||
|
(`RECOMMENDATION_STALE`), the UI shows the "situation has changed" message, and the
|
||||||
|
user must review again before a new apply is possible.
|
||||||
|
- **MO-016 ordering**: both orders tested. Resolving the overlap first correctly leaves
|
||||||
|
nothing to apply (vehicle stays "available", genuinely correct). Resolving the status
|
||||||
|
conflict first safely blocks the vehicle; resolving the now-redundant overlap
|
||||||
|
afterwards does not disturb it. Neither order ever produces "rented".
|
||||||
|
|
||||||
|
## Knowledge (per language)
|
||||||
|
|
||||||
|
The brief's exact validation question, in each language, grounds on the damage
|
||||||
|
procedure as the **primary** (not just top-3) source:
|
||||||
|
- nl-BE: *"Wat moet ik doen wanneer een voertuig beschadigd terugkomt?"* → damage
|
||||||
|
procedure, Dutch source, Dutch excerpt.
|
||||||
|
- en-GB: *"What should I do when a vehicle returns with damage?"* → damage procedure,
|
||||||
|
English source, English excerpt.
|
||||||
|
- fr-BE: *"Que dois-je faire lorsqu'un véhicule revient endommagé ?"* → damage
|
||||||
|
procedure, French source, French excerpt.
|
||||||
|
|
||||||
|
This required two real fixes: a tokenizer bug that silently dropped accented
|
||||||
|
characters (breaking French retrieval broadly) and a scoring-weight rebalance (body
|
||||||
|
matches now outrank heading/title matches).
|
||||||
|
|
||||||
|
## Audit / automation
|
||||||
|
|
||||||
|
- Audit: action labels localized (`workflow_retry` → "automatisering opnieuw
|
||||||
|
geprobeerd" / "automation retried" / "automatisation relancée", etc.), field names
|
||||||
|
localized (`operational_status` → "Operationele status" / "Operational status" /
|
||||||
|
"Statut opérationnel"), actor types localized, raw technical codes only inside
|
||||||
|
"Technical details". Verified live and via a dedicated Playwright test.
|
||||||
|
- Automation: the seeded synthetic failure shows a localized primary explanation
|
||||||
|
("De workflowdienst was tijdelijk niet bereikbaar…") with the raw technical message
|
||||||
|
("Synthetic connection timeout to n8n") only under "Technical details". Verified live
|
||||||
|
and via a dedicated Playwright test.
|
||||||
|
|
||||||
|
## Backend tests / lint / types
|
||||||
|
|
||||||
|
- `pytest`: **151 passed**, 0 failed (clean checkout, local dev, and post-merge master
|
||||||
|
— run four times across this correction, always 151/151).
|
||||||
|
- `ruff check .`: all checks passed, every run.
|
||||||
|
- `mypy app` (strict): no issues found in 49 source files, every run.
|
||||||
|
- Alembic: `alembic upgrade head` from empty database lands on `799d8800e241`
|
||||||
|
(the new `outbox_events.last_error_code` column); `downgrade -1` / `upgrade head`
|
||||||
|
round-trip verified.
|
||||||
|
|
||||||
|
## Frontend build / Playwright
|
||||||
|
|
||||||
|
- `npm ci`, `tsc -b`, `vite build`: clean, every run.
|
||||||
|
- Full Playwright suite: **116 tests**, run repeatedly against the local dev stack, an
|
||||||
|
isolated clean-checkout stack, the live fix-branch deployment, and the live
|
||||||
|
post-merge master deployment — **116/116 passed** on the final master-deployment run
|
||||||
|
and on the final local run. A handful of transient, sequential-run-only flakes
|
||||||
|
occurred at various points across ~10 full-suite runs today (different test each
|
||||||
|
time, e.g. a pre-existing logout-timing race in `AuthContext.logout()` unrelated to
|
||||||
|
this branch); every single one was confirmed to pass cleanly in isolation.
|
||||||
|
- Guided demo covered indirectly via `guided-demo-full.spec.ts`,
|
||||||
|
`demo-guide.spec.ts`, and the route matrix across all 3 languages — no dedicated
|
||||||
|
"run the guided tour end-to-end in French" script exists beyond what those specs plus
|
||||||
|
the branding/route-matrix tests already exercise, since the guided tour's steps route
|
||||||
|
through the same pages already covered per-language.
|
||||||
|
|
||||||
|
## Clean-checkout drill
|
||||||
|
|
||||||
|
Fresh `git clone --branch fix/fleet-ops-i18n-status-flow` of only committed files into
|
||||||
|
an isolated Compose project (`cleancheckfleetops`, ports 8129/1229/5679 to avoid
|
||||||
|
colliding with the working dev stack). From empty volumes: build → up → `alembic
|
||||||
|
upgrade head` → `reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27
|
||||||
|
data-quality issues / 20 workflow runs) → 151 backend tests + Ruff + mypy green →
|
||||||
|
frontend build green → full Playwright suite green → final reset →
|
||||||
|
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes, and images
|
||||||
|
torn down afterward; working dev environment confirmed untouched.
|
||||||
|
|
||||||
|
## Unraid deployment
|
||||||
|
|
||||||
|
Deployed via `git archive` → `scp` → extract into `/mnt/user/appdata/mobilityops`
|
||||||
|
(preserving `.env` and persistent volumes) → `.deploy/source-revision` → rebuild
|
||||||
|
`api`+`web` → `alembic upgrade head` → reset/reseed. Done twice: once for the fix
|
||||||
|
branch (caught the evidence-summary bug), once for the final merged master. Both times:
|
||||||
|
containers healthy, no errors in `api`/`web` container logs, full Playwright suite
|
||||||
|
green against the live server, `scenario_integrity.all_ready: true` after final reset.
|
||||||
|
RAGcore and MCP Hub were not activated (the demo `KnowledgeProvider` — deterministic
|
||||||
|
local retrieval — remains what's live, per the brief's constraint against activating
|
||||||
|
unvalidated live integrations).
|
||||||
|
|
||||||
|
## Responsive / accessibility
|
||||||
|
|
||||||
|
- Breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844,
|
||||||
|
360×800) × 3 languages: no horizontal overflow, localized headings visible
|
||||||
|
(`responsive-i18n.spec.ts`).
|
||||||
|
- Status-recommendation panel: keyboard-only activation of "Review recommendation" and
|
||||||
|
"Change status to X" verified via focus assertions (not just click); reduced-motion
|
||||||
|
emulated during the flow; status never conveyed by colour alone (the badge always
|
||||||
|
carries its own localized text); `aria-live="polite"` added so the applied
|
||||||
|
confirmation is announced to screen readers.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- A pre-existing, narrow timing race in `AuthContext.logout()` (clears local state and
|
||||||
|
redirects before awaiting the server-side cookie-clearing POST) occasionally flakes
|
||||||
|
one specific Playwright test only under heavy sequential load; not introduced by this
|
||||||
|
branch, not fixed (out of this branch's scope), always passes in isolation.
|
||||||
|
- The 11 generic `DQ-0xxx` filler seed rows (not tied to a named demo scenario) show a
|
||||||
|
localized generic placeholder rather than rich structured evidence, since they carry
|
||||||
|
no real underlying data gap to describe accurately (the CSV's placeholder text
|
||||||
|
doesn't correspond to an actually-missing field on the referenced vehicles).
|
||||||
|
- No dedicated "full guided demo in French, screenshot every step" script exists as a
|
||||||
|
single artifact; coverage is composed from the route matrix, branding, and existing
|
||||||
|
guided-demo specs, each run across all 3 languages.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
`artifacts/fleet-ops-correction/screenshots/`, all captured live against
|
||||||
|
`http://192.168.10.150:1236`:
|
||||||
|
|
||||||
|
- `login-nl-BE.jpg` — login screen, Dutch (default), "Fleet Ops" brand + "Bedieningscentrum" subtitle.
|
||||||
|
- `login-fr-BE.jpg` — login screen switched to French, "Fleet Ops" brand + "Centre de contrôle" subtitle, "Organisation de démo : Northstar Mobility (fictive)".
|
||||||
|
- `dq-demo-status-fr-BE-collapsed.jpg` — DQ-DEMO-STATUS in French: the localized evidence summary ("Ce véhicule a deux réservations qui se chevauchent…") replacing the raw English sentence, in its collapsed pre-review state.
|
||||||
|
- `dq-demo-status-fr-BE-clean-reload.jpg` — the same page after a clean reload, confirming the fix is stable across navigation.
|
||||||
|
|
||||||
|
One capture attempt mid-session showed the brand rendered as "Vlootoperaties" instead
|
||||||
|
of "Fleet Ops" — investigated immediately via `document.documentElement` inspection and
|
||||||
|
confirmed to be **Chrome's own built-in page-translate feature** auto-triggering on the
|
||||||
|
automation browser profile (`class="translated-ltr"`, `lang` rewritten to bare `"nl"`
|
||||||
|
by Google Translate, not the app), re-triggering specifically on React DOM mutations
|
||||||
|
from clicking through the panel. Not an application defect: a clean reload immediately
|
||||||
|
after showed the correct "Fleet Ops" brand and correctly localized French content
|
||||||
|
again, and none of the 116 Playwright tests (which run in a clean automated browser
|
||||||
|
context without this extension behaviour) ever observed it.
|
||||||
|
|
||||||
|
## Rollback procedure
|
||||||
|
|
||||||
|
1. `ssh unraid`, `cd /mnt/user/appdata/mobilityops`.
|
||||||
|
2. `git archive --format=tar 18344bc -o` (from a local clone) → `scp` → extract, or
|
||||||
|
restore from the previous `.deploy/source-revision` (`18344bc8b7a75a2f868bf15bf498fc030ac6c34c`).
|
||||||
|
3. `echo 18344bc8b7a75a2f868bf15bf498fc030ac6c34c > .deploy/source-revision`.
|
||||||
|
4. `docker compose -f compose.yaml -f compose.unraid.yaml build api web && ... up -d api web`.
|
||||||
|
5. `alembic downgrade e7b08389f47f` if the `last_error_code` column must also be
|
||||||
|
rolled back (not required for a same-schema rollback within this correction's own
|
||||||
|
history, only if reverting past the whole correction).
|
||||||
|
6. Re-seed and re-verify `scenario_integrity.all_ready: true`.
|
||||||
|
|
||||||
|
The fix branch `fix/fleet-ops-i18n-status-flow` was not deleted.
|
||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,291 @@
|
|||||||
|
# Fleet Ops final localization — final summary
|
||||||
|
|
||||||
|
Small, targeted correction round on top of the already-merged, functionally-validated
|
||||||
|
Fleet Ops correction milestone. Scope: remaining NL/FR translation gaps, centralized
|
||||||
|
API-error localization, a time-dependent Europe/Brussels dashboard greeting, i18n
|
||||||
|
test hardening, and documentation consistency — explicitly no redesign, no business-logic
|
||||||
|
changes, no new functionality. Audit and rationale: `docs/fleet-ops-final-localization/audit.md`.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Stage | Commit | Message |
|
||||||
|
|---|---|---|
|
||||||
|
| Start commit (branch base = prior `origin/master` head) | `f7805579f7c73bd3085d73a725fa985b4a4892ed` | `docs(release): final Fleet Ops correction evidence and screenshots` |
|
||||||
|
| Final fix-branch commit | `09173a4740ddb282fe5412c5305284e9776d397c` | `fix: correct fr-BE audit column label Actor -> Auteur` |
|
||||||
|
| Merge commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | `merge: finalize Fleet Ops localization` |
|
||||||
|
| Final master commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | (same as merge commit — merge commit is the branch tip) |
|
||||||
|
| Deployed commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | matches `.deploy/source-revision` on Unraid exactly |
|
||||||
|
|
||||||
|
Branch used: `fix/fleet-ops-final-i18n-ux` (the brief named `fix/fleet-ops-final-localization`;
|
||||||
|
this branch was verified freshly and cleanly branched from `origin/master` with a clean
|
||||||
|
working tree, so it was used as-is rather than renamed — see the audit doc's naming note).
|
||||||
|
`origin/master` was re-fetched and confirmed unchanged (`f780557`) immediately before the
|
||||||
|
merge, per the mandatory pre-merge safety check.
|
||||||
|
|
||||||
|
Full commit sequence (oldest to newest):
|
||||||
|
|
||||||
|
```
|
||||||
|
1fbb20b docs: audit remaining Fleet Ops localization gaps
|
||||||
|
37a362c fix: translate remaining NL/FR interface gaps
|
||||||
|
94cfb7b test: tighten i18n allowlist, add substring and brand-leak guards
|
||||||
|
d17af1c feat: centralize API error localization
|
||||||
|
e427313 feat: add time-dependent Europe/Brussels dashboard greeting
|
||||||
|
77208b8 fix: prevent topbar overflow from an unbreakable Dutch role-name translation
|
||||||
|
f0d6411 fix: serve the missing Fleet Ops favicon
|
||||||
|
9468cc3 docs: update PROJECT_STATE and README for the final localization round
|
||||||
|
09173a4 fix: correct fr-BE audit column label Actor -> Auteur
|
||||||
|
5f0eaa5 merge: finalize Fleet Ops localization
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product name and supported languages
|
||||||
|
|
||||||
|
- Visible product name: **Fleet Ops**, everywhere, never translated (`frontend/src/product.ts`
|
||||||
|
constant, interpolated as `{{productName}}`). "MobilityOps" remains the internal repo /
|
||||||
|
Compose project / deployment-directory identifier only.
|
||||||
|
- Supported UI languages: **nl-BE** (default), **en-GB**, **fr-BE**.
|
||||||
|
- No visible "MobilityOps" or the word "PoC" anywhere in the UI (enforced by a dedicated
|
||||||
|
automated test, see below).
|
||||||
|
|
||||||
|
## Corrected translations
|
||||||
|
|
||||||
|
- Role names actually translated (not just labelled as translated): `auth.json` /
|
||||||
|
`demo.json` role keys — **Operationsmanager** / **Verhuurmedewerker** (nl-BE),
|
||||||
|
**Responsable des opérations** / **Collaborateur de location** (fr-BE).
|
||||||
|
- `audit.title` → **Auditgeschiedenis** / **Piste d'audit**; `columns.actor` → **Uitvoerder**
|
||||||
|
(nl-BE) / **Auteur** (fr-BE, corrected during live browser validation — see Known
|
||||||
|
limitations).
|
||||||
|
- `list.statusOpen` → **Openstaand**; `ledger.filterRecent` → **Recentste**;
|
||||||
|
`scenarios.startScenario` → **Scenario starten** / **Démarrer le scénario**.
|
||||||
|
- 8 previously-missed mid-sentence "Audit trail" leaks fixed across `demo.json`,
|
||||||
|
`quality.json`, `returns.json` (nl-BE) — found by the new embedded-substring test, not
|
||||||
|
the pre-existing whole-string-identity test, which structurally cannot catch this class
|
||||||
|
of bug.
|
||||||
|
- No unintended English text remains in nl-BE or fr-BE (see translation-coverage evidence
|
||||||
|
below).
|
||||||
|
|
||||||
|
## Removed allowlist exceptions
|
||||||
|
|
||||||
|
Removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries in `i18n-coverage.spec.ts`:
|
||||||
|
`audit.title`, `auth.roleOperationsManager`, `auth.roleRentalEmployee`,
|
||||||
|
`demo.scenarios.startScenario`, `demo.scenarios.roles.operations_manager`,
|
||||||
|
`demo.scenarios.roles.rental_employee`, `navigation.items.audit` — all now genuinely
|
||||||
|
translated; their old comments describing them as "deliberately untranslated" were no
|
||||||
|
longer true. Two new tests added: embedded-English/Dutch-substring leak guard, and a
|
||||||
|
no-"MobilityOps"/no-"PoC" guard.
|
||||||
|
|
||||||
|
## Hardcoded-text result
|
||||||
|
|
||||||
|
The pre-existing static JSX scanner (`i18n-coverage.spec.ts`, section 11D) found **zero**
|
||||||
|
hardcoded user-facing strings outside the approved technical-token allowlist (Fleet Ops,
|
||||||
|
Northstar Mobility, ITWorx MCP Hub) across `pages/` and `components/`. Result: **PASS**.
|
||||||
|
|
||||||
|
## API-error-localization result
|
||||||
|
|
||||||
|
New `frontend/src/api/errorMessages.ts` (`describeApiError`) replaces the
|
||||||
|
`err instanceof ApiError ? err.message : t(fallback)` anti-pattern (which showed raw
|
||||||
|
English backend text for the common case) at all 13 call sites across 7 files
|
||||||
|
(`Automation.tsx`, `ReturnForm.tsx`, `DataQuality.tsx`, `DemoGuide.tsx`, `Layout.tsx`,
|
||||||
|
`DataQualityIssueDetail.tsx` ×7 sites, `Knowledge.tsx`). Resolution order: known `AppError`
|
||||||
|
code (32 codes) → known HTTP status (401/403/404/409/422/500) → fully generic fallback.
|
||||||
|
New `ApiErrorNotice` component (`PageChrome.tsx`) always renders a localized title +
|
||||||
|
explanation + optional next step; raw backend text is demoted to a "Technical
|
||||||
|
details"/"Détails techniques" disclosure, never the primary message.
|
||||||
|
|
||||||
|
Evidence: `frontend/e2e/error-messages.spec.ts` (10 tests, all passing) —
|
||||||
|
every known code/status has non-empty copy in all 3 locales; a known code never surfaces
|
||||||
|
raw text as the primary message; unknown-code and unknown-status fallback chains behave
|
||||||
|
correctly; a drift guard greps the actual backend `AppError("CODE", ...)` call sites and
|
||||||
|
confirms `KNOWN_CODES` exactly matches (32 codes, zero drift). Live-verified on Unraid: the
|
||||||
|
seeded failed automation run renders a fully localized French error with a "DÉTAILS
|
||||||
|
TECHNIQUES" disclosure below it.
|
||||||
|
|
||||||
|
## Greeting logic and edge cases
|
||||||
|
|
||||||
|
New `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, clock-injectable, pure) resolves
|
||||||
|
one of 4 periods against **Europe/Brussels** wall-clock time via
|
||||||
|
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })` (DST-safe by
|
||||||
|
construction — no manual UTC-offset math):
|
||||||
|
|
||||||
|
| Period | Window | nl-BE | en-GB | fr-BE |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| morning | 05:00–11:59 | Goedemorgen | Good morning | Bonjour |
|
||||||
|
| afternoon | 12:00–17:59 | Goedemiddag | Good afternoon | Bonjour |
|
||||||
|
| evening | 18:00–22:59 | Goedenavond | Good evening | Bonsoir |
|
||||||
|
| night | 23:00–04:59 | Welkom terug | Welcome back | Bon retour |
|
||||||
|
|
||||||
|
Never "Goedenacht" (a farewell in Dutch, not a welcome). Each period also has its own
|
||||||
|
accompanying sentence per language (`dashboard.json` `greetingBody`), replacing the old
|
||||||
|
fixed "Here's the fleet." `useGreetingPeriod.ts` polls every 30s so the greeting rolls
|
||||||
|
over live while the app stays open, no reload required; initial render uses a synchronous
|
||||||
|
`useState(() => getGreetingPeriod())` so there is never a flash of the wrong period.
|
||||||
|
|
||||||
|
Edge-case evidence:
|
||||||
|
- `frontend/e2e/greeting.spec.ts` (4 tests): exact boundary checks at 04:59/05:00/11:59/
|
||||||
|
12:00/17:59/18:00/22:59/23:00 in both CET (winter) and CEST (summer), plus a dedicated
|
||||||
|
spring-forward/fall-back DST-transition test (2026-03-29 and 2026-10-25).
|
||||||
|
- `frontend/e2e/greeting-live.spec.ts` (6 tests, real browser via Playwright's `page.clock`):
|
||||||
|
all 8 boundary times rendered correctly in **all 3 languages** against the actual app;
|
||||||
|
live period rollover with no `page.reload()` call anywhere in that test; language-switch
|
||||||
|
behaviour without changing the time period; the "never Goedenacht" guard.
|
||||||
|
- Live-verified on Unraid at actual current server time (2026-08-04, ~03:2x CEST, i.e. the
|
||||||
|
night period): dashboard showed "Welkom terug. Hier is het laatste overzicht van je
|
||||||
|
wagenpark." (nl-BE), "Welcome back. Here's the latest overview of your fleet." (en-GB),
|
||||||
|
"Bon retour. Voici le dernier aperçu de votre flotte." (fr-BE).
|
||||||
|
|
||||||
|
## README / PROJECT_STATE corrections
|
||||||
|
|
||||||
|
- `PROJECT_STATE.md`: fixed the stale "Product name: MobilityOps." / "PoC only"
|
||||||
|
locked-decisions lines (predated the Fleet Ops rebrand); fixed the "Fleet Ops
|
||||||
|
correction" section header, which still read "IN PROGRESS .../Not yet merged to
|
||||||
|
master" despite already being merged (`de0bdea` / `f780557`); appended a new dated
|
||||||
|
entry for this correction round (not a rewrite of prior entries, per the brief's
|
||||||
|
explicit instruction not to hide earlier history).
|
||||||
|
- `README.md`: linked `docs/fleet-ops-final-localization/` alongside the existing
|
||||||
|
correction-round doc link; refreshed the stale Playwright test count (113 → 138 → 139
|
||||||
|
after the favicon regression test was added).
|
||||||
|
|
||||||
|
## Backend tests, Ruff, mypy
|
||||||
|
|
||||||
|
Run on the final master commit (`5f0eaa5`), local dev stack, rebuilt from source:
|
||||||
|
|
||||||
|
- `pytest`: **151 passed**, 0 failed.
|
||||||
|
- `ruff check .`: **All checks passed!**
|
||||||
|
- `mypy app` (the project's canonical invocation, matching all prior milestone gates —
|
||||||
|
no `[tool.mypy]` strict config exists in `pyproject.toml`): **Success: no issues found
|
||||||
|
in 49 source files.**
|
||||||
|
|
||||||
|
No backend Python was touched this round; these numbers are unchanged from the prior
|
||||||
|
correction milestone's final gate, confirmed green again on the current tree.
|
||||||
|
|
||||||
|
## Frontend build, Playwright
|
||||||
|
|
||||||
|
- `npx tsc --noEmit`: clean, 0 errors.
|
||||||
|
- `npm run build` (`tsc -b && vite build`): clean production build.
|
||||||
|
- Full Playwright suite (`npx playwright test`), master build, local dev stack:
|
||||||
|
**139 passed**, 0 failed (confirmed on a clean run after two transient
|
||||||
|
`0xC0000005` Chromium worker crashes caused by this specific machine running 43+
|
||||||
|
concurrent Chrome processes at the time — see Known limitations; a targeted 48-test
|
||||||
|
re-run of every new/changed suite also passed cleanly in between).
|
||||||
|
|
||||||
|
## Clean-checkout drill
|
||||||
|
|
||||||
|
Isolated Compose project `mobilityops-clean` (ports 8129/1229/5679, no shared volumes/
|
||||||
|
network with the working dev stack), fresh `git clone --branch
|
||||||
|
fix/fleet-ops-final-i18n-ux` of only committed files:
|
||||||
|
|
||||||
|
1. `docker compose build` + `up -d` from empty volumes — all 4 containers healthy.
|
||||||
|
2. `alembic upgrade head` → `799d8800e241 (head)`.
|
||||||
|
3. `seed --reset` → 2 users / 180 customers / 50 vehicles / 246 bookings / 75 inspections /
|
||||||
|
40 maintenance / 27 data-quality issues / 20 workflow runs — matches the documented
|
||||||
|
deterministic count exactly.
|
||||||
|
4. Backend gates: `pytest` 151 passed, `ruff check .` clean, `mypy app` clean (49 files).
|
||||||
|
5. Frontend: `npm ci` clean, `tsc --noEmit` clean, `vite build` clean.
|
||||||
|
6. Full Playwright suite against the isolated stack (`MOBILITYOPS_PUBLIC_URL=http://localhost:1229`):
|
||||||
|
**139 passed**, 0 failed — this run covers the Dutch/English/French language checks,
|
||||||
|
greeting boundaries, API error paths, and the guided demo, all in one pass.
|
||||||
|
7. Final reset + `scenario_integrity`: all 5 scenarios `ready: true`.
|
||||||
|
8. Isolated stack, containers, volumes and images torn down; original dev environment
|
||||||
|
confirmed untouched (`mobilityops-*` containers unaffected throughout).
|
||||||
|
|
||||||
|
**PASS.**
|
||||||
|
|
||||||
|
## Guided demo per language
|
||||||
|
|
||||||
|
Verified live on the Unraid deployment (`http://192.168.10.150:1236`) in all 3 languages
|
||||||
|
via direct browser interaction: login screen role buttons, dashboard (greeting, readiness
|
||||||
|
band, attention queue, integration pulse, recent activity), audit trail, automation retry
|
||||||
|
flow with localized error + technical-details disclosure, and demo reset — all rendering
|
||||||
|
correctly in nl-BE, en-GB and fr-BE. The full guided-demo Playwright spec
|
||||||
|
(`guided-demo-full.spec.ts`) passed as part of the 139-test suite on both the local dev
|
||||||
|
stack and the isolated clean-checkout stack.
|
||||||
|
|
||||||
|
## Server deployment, container health
|
||||||
|
|
||||||
|
Deployed to `http://192.168.10.150:1236` (Compose project `mobilityops`,
|
||||||
|
`/mnt/user/appdata/mobilityops`), preserving the server's existing `.env`, the Postgres
|
||||||
|
and n8n named volumes, the exposed port, and the deployment directory — only `api` and
|
||||||
|
`web` were rebuilt/recreated; `db` was never touched beyond `alembic upgrade head`; no
|
||||||
|
second n8n instance was started (shared existing n8n at `:5678` used throughout).
|
||||||
|
|
||||||
|
Procedure (matching `docs/demo-release/demo-runbook.md` exactly): `git archive` → `scp` →
|
||||||
|
extract over the existing deployment dir → update `.deploy/source-revision` →
|
||||||
|
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||||
|
→ confirm `alembic current` → `seed --reset`.
|
||||||
|
|
||||||
|
Final container status:
|
||||||
|
|
||||||
|
```
|
||||||
|
mobilityops-api-1 Up (healthy)
|
||||||
|
mobilityops-db-1 Up (healthy)
|
||||||
|
mobilityops-web-1 Up (healthy)
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployed twice this round: once for the fix-branch tip (`09173a4`, with full live
|
||||||
|
3-language validation), once for the final master merge commit (`5f0eaa5`) after the
|
||||||
|
merge — both deployments passed migrations, reseed, and a live smoke test.
|
||||||
|
|
||||||
|
## Repository / runtime hash comparison
|
||||||
|
|
||||||
|
```
|
||||||
|
git rev-parse HEAD (local, master) = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||||
|
/mnt/user/appdata/mobilityops/.deploy/source-revision = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exact match.**
|
||||||
|
|
||||||
|
## Browser console and network
|
||||||
|
|
||||||
|
No console errors on any checked route in any of the 3 languages (dashboard, audit,
|
||||||
|
automation, login) on the live Unraid deployment. All observed `/api/` network requests
|
||||||
|
returned `200`. `api` and `web` container logs show no errors/tracebacks/exceptions after
|
||||||
|
the final deployment.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **Transient `document.documentElement.lang` DOM-attribute anomaly during interactive
|
||||||
|
manual browser testing** on the live server: on 2 occasions, right after a client-side
|
||||||
|
action (an automation retry click; a demo-reset confirm click), `document.documentElement.lang`
|
||||||
|
briefly showed `"nl"` while the actually-rendered page content, `localStorage`, and a
|
||||||
|
controlled repeat of the exact same click sequence (fresh login, single deliberate
|
||||||
|
click, immediate inspection) all remained correctly `"fr-BE"`. Root-caused as far as
|
||||||
|
possible: the codebase has exactly one `i18n.changeLanguage()` call site
|
||||||
|
(`LanguageSwitcher.tsx`), which was not invoked in the clean repro, and `t()` /
|
||||||
|
`i18n.language` are structurally coupled through a single i18next singleton with no
|
||||||
|
code path capable of producing this split state. Not reproduced even once across 139
|
||||||
|
automated Playwright tests run 3 times total (local pre-merge, isolated clean-checkout,
|
||||||
|
local post-merge on master) in a clean, extension-free browser context. Most likely
|
||||||
|
explanation: a third-party browser extension active in the specific interactive testing
|
||||||
|
session (which also had ~10 unrelated pre-existing tabs open on the same origin, and
|
||||||
|
showed independent signs of instability — repeated CDP screenshot timeouts) rewriting
|
||||||
|
the `lang` attribute based on its own content heuristics, independent of the React app.
|
||||||
|
Logged here for transparency rather than silently dismissed; does not affect any
|
||||||
|
automated PASS result above.
|
||||||
|
- **Two transient Chromium worker crashes** (`0xC0000005` / access violation) during the
|
||||||
|
master-build Playwright re-run, on a machine that had accumulated 43+ concurrent Chrome
|
||||||
|
processes from the interactive testing session above. A clean run immediately
|
||||||
|
afterward (fewer processes) passed all 139 tests; a 48-test targeted re-run of every
|
||||||
|
new/changed suite also passed cleanly in between. Treated as machine resource
|
||||||
|
contention, not a code defect — consistent with the prior correction milestone's own
|
||||||
|
documented experience of "sequential-run-only flakes reproduced from resource
|
||||||
|
contention of running two full Docker stacks at once," per `PROJECT_STATE.md`.
|
||||||
|
- One translation gap (fr-BE `audit.columns.actor`: "Acteur" instead of the brief's
|
||||||
|
specified "Auteur") was missed in the initial pass and only caught during live browser
|
||||||
|
validation on Unraid; fixed in commit `09173a4` and redeployed before the master merge.
|
||||||
|
- The Fleet Ops brand mark (`BrandMark` in `Icons.tsx`) was flagged by the user as
|
||||||
|
potentially due for a visual refresh; per explicit user decision mid-session, this is
|
||||||
|
out of scope for this correction round and deferred to a separate follow-up task.
|
||||||
|
- No RAGcore/MCP Hub implementation changes were made or claimed; both remain in the same
|
||||||
|
demo/not-connected state documented by the prior correction milestone.
|
||||||
|
|
||||||
|
## Rollback procedure
|
||||||
|
|
||||||
|
`.deploy/source-revision` on the server records exactly which commit is live. To roll
|
||||||
|
back: `ssh unraid`, extract an earlier `source-<short-sha>.tar.gz` from
|
||||||
|
`/mnt/user/appdata/mobilityops/.deploy/` (prior tarballs remain in place, including
|
||||||
|
`source-9468cc3e.tar.gz`, `source-09173a4.tar.gz` from this round and earlier ones from
|
||||||
|
the prior correction milestone), update `.deploy/source-revision` to match, and re-run
|
||||||
|
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
|
||||||
|
followed by `alembic upgrade head` (migrations are additive only — no destructive
|
||||||
|
migration exists on this branch, so no database rollback is needed). No secrets were
|
||||||
|
printed or read at any point in this process (`.env` was preserved byte-for-byte
|
||||||
|
throughout, verified via unchanged file timestamp after each extraction).
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Fleet Ops release — final-product-polish evidence
|
||||||
|
|
||||||
|
## Result: PASS
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- Original feature-branch baseline before this task: `257a4cf` (`docs(polish): audit finale demo-afwerking`)
|
||||||
|
- Feature-branch commits added this task, on `feat/mobilityops-functional-completion`:
|
||||||
|
- `337f871` — polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
|
||||||
|
- `845db14` — fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
|
||||||
|
- Feature branch final commit: `845db14e172539b1d10e40f6a3249a72122deb41`
|
||||||
|
- `master` before merge (verified against the previously recorded baseline): `e0c7ed60112510687627d20a957af91c8b9db7f8` — unchanged, no unexpected commits, no conflicts (confirmed via `git merge-tree` dry run before merging)
|
||||||
|
- Merge commit on `master`: `18a765d62345ea9a6660d04fb868f218cf4d0b6e` (`merge: release Fleet Ops multilingual demo`, `--no-ff`)
|
||||||
|
- Final `master` commit (pushed and deployed): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
|
||||||
|
- Deployed commit on Unraid (`.deploy/source-revision`): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
|
||||||
|
- Feature branch was **not** deleted, per instruction.
|
||||||
|
|
||||||
|
## URL
|
||||||
|
|
||||||
|
- Live review deployment: `http://192.168.10.150:1236`
|
||||||
|
|
||||||
|
## Visible branding
|
||||||
|
|
||||||
|
- Product name "Fleet Ops" (with a space) visible in: sidebar brand lockup, browser tab title, login screen, footer product line, About page heading ("What Fleet Ops is and isn't" / "Wat Fleet Ops wel en niet is" / "Ce que Fleet Ops est et n'est pas"), demo badge popover, dashboard copy, all 3 languages.
|
||||||
|
- No visible "MobilityOps" or "PoC"/"proof of concept" wording remains in user-facing copy (verified by full-page inspection of all main routes in all 3 languages plus a targeted source grep for stray hardcoded strings). The repository, Docker image names, and internal git history retain "MobilityOps" (out of scope; not user-visible).
|
||||||
|
- Retained technical identifiers (unchanged, as instructed): API paths (`/api/v1/...`), Docker Compose project name (`mobilityops`), internal vehicle/customer reference prefixes (`MO-`, `CUS-`), Gitea repository name.
|
||||||
|
|
||||||
|
## Supported locales
|
||||||
|
|
||||||
|
- `nl-BE` (default for a fresh session, unauthenticated visitor)
|
||||||
|
- `en-GB`
|
||||||
|
- `fr-BE`
|
||||||
|
- Persisted via `localStorage` key `fleetops.language`; survives refresh, logout/login, and demo reset. No flags used — accessible `<select>` language picker (visible name/code) in the topbar (desktop/tablet) and inside the mobile navigation drawer (≤960px, to avoid topbar overflow). `document.documentElement.lang` kept in sync. All dates/numbers rendered via `Intl.DateTimeFormat`/`Intl.NumberFormat` (`Europe/Brussels` timezone).
|
||||||
|
|
||||||
|
## Translation coverage
|
||||||
|
|
||||||
|
- `frontend/e2e/i18n-coverage.spec.ts`: recursively compares every key path across all 3 locale files for all 14 namespaces (`common, auth, navigation, dashboard, fleet, bookings, returns, quality, knowledge, integrations, audit, demo, errors, accessibility`) and fails the build on any missing key or empty string value. **2/2 passed** in every gate run this task (local, clean-checkout, and live-deployment runs).
|
||||||
|
- Command: `npx playwright test e2e/i18n-coverage.spec.ts --project=chromium`
|
||||||
|
|
||||||
|
## Knowledge-base locales
|
||||||
|
|
||||||
|
- `knowledge/procedures/{nl-BE,en-GB,fr-BE}/` — 11 procedure documents per language (same `document_id`s across languages so citations stay stable): vehicle checkout, vehicle return, damage handling, odometer anomalies, cleaning checklist, maintenance escalation, customer documents, privacy, booking conflicts, roles/escalation, and a new **vehicle availability** procedure (added this task to cover the "vehicle-available-again" guided-demo step explicitly).
|
||||||
|
- `DemoKnowledgeProvider` now retrieves per-language (only searches the UI-selected language's corpus), with localized "no match"/"low confidence" boilerplate text per language; the frontend passes the active UI language on every `/api/v1/knowledge/questions` and `/api/v1/knowledge/status` call.
|
||||||
|
- Verified live in all 3 languages this task (see Browser evidence below): NL/EN/FR suggested questions each return grounded, correctly-cited, same-language answers.
|
||||||
|
- Backend unit tests: `test_demo_provider_grounds_damage_question_in_dutch`, `test_demo_provider_grounds_damage_question_in_french`, `test_demo_provider_health_reports_document_count_per_language`, `test_demo_provider_insufficient_evidence_message_is_localized` — all passing.
|
||||||
|
|
||||||
|
## Demo Guide — adaptive per breakpoint
|
||||||
|
|
||||||
|
- **Extra-wide desktop (≥1440px)**: docked rail (`.demo-guide-panel.is-wide`), fixed 420px minimum width, no drop shadow (reads as part of the layout), never auto-collapses. Verified: `demo-guide.spec.ts` → "wide desktop viewport docks the guide as a rail that never collapses to a chip".
|
||||||
|
- **Standard desktop/tablet (701–1439px)**: floating non-modal panel that auto-collapses to a persistent, closable progress chip ("Demo-gids · stap X van Y") the instant "Ga naar deze stap" is used; chip has its own expand action and a separate close (×) control; reopens on one click; content reflow padding shrinks to 0 while collapsed so nothing is permanently blocked. Verified: 3 dedicated tests in `demo-guide.spec.ts`.
|
||||||
|
- **Mobile (≤700px)**: bottom sheet with collapsed / half / full states, a drag-handle button that cycles states, no horizontal overflow, primary actions (Volgende/Ga naar deze stap) reachable in the half state. Verified: `demo-guide.spec.ts` → "mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", plus `demo-accessibility.spec.ts` → "demo guide is usable as a mobile bottom sheet".
|
||||||
|
- **Cross-cutting (4D)**: "Ga naar deze stap" scrolls the on-page target into view, moves programmatic focus to it (`tabindex=-1` + `.focus()`), and applies a 2.2s outline pulse (`.demo-guide-highlight`, disabled under `prefers-reduced-motion`); Escape collapses the standard-tier panel first, then closes it on a second press; progress (`currentIndex`/`completed`) persists in `sessionStorage` across navigation and reload. Verified: `demo-guide.spec.ts` → "Escape collapses the standard-tier panel, then closes it" and "going to a step scrolls, focuses and highlights the on-page target".
|
||||||
|
- Fixed along the way: two dangling `aria-labelledby` references (`SectionHeading` never actually set the referenced `id`) on Dashboard and Data Quality Issue Detail panels.
|
||||||
|
|
||||||
|
## Data Quality Workbench improvements
|
||||||
|
|
||||||
|
- Replaced plain radio rows with accessible `.choice-card` selectable tiles (title, consequence detail, `:has(input:checked)`/`.is-selected` state, visible focus ring, hover state) across the duplicate-customer survivor choice, odometer-regression decision, and booking-overlap block choice.
|
||||||
|
- Clear action hierarchy: primary resolve/apply/merge action uses `.button-primary`; defer uses a de-emphasized `.button-tertiary`; reject uses `.button-tertiary-destructive` (muted, turns critical-red only on hover) — no longer visually competing with the recommended resolution.
|
||||||
|
- Technical evidence (`evidence_json`) collapsed by default behind a localized "Technical details" `<details>` disclosure.
|
||||||
|
- Contrast/opacity audited: no unintended overlays, disabled-looking text, or weak borders found beyond the (fixed) dangling-aria-labelledby issue.
|
||||||
|
|
||||||
|
## Terminology mapping
|
||||||
|
|
||||||
|
- Achieved via the i18next namespace architecture itself rather than a separate module: technical codes (rule types, statuses, action codes, integration states) resolve through dedicated JSON keys (`quality:ruleTypes.*`, `quality:list.status*`, `audit:actions.*`, `integrations:statusLabels.*`, `fleet:statuses.*`, `bookings:statuses.*`) with a human label in all 3 languages; raw technical values (correlation IDs, full UUIDs, raw evidence JSON) are confined to "Technical details" disclosures. Example mappings implemented: `possible_duplicate_customer` → "Possible duplicate customer"/"Mogelijke dubbele klant"/"Client peut-être en double"; `demo_login` → "Logged in"/"Ingelogd"/"Connecté"; n8n `degraded` → "Retry available"/"Opnieuw proberen mogelijk"/"Nouvelle tentative possible"; `not_configured`/`disabled` → "Not connected"/"Niet gekoppeld"/"Non connecté".
|
||||||
|
|
||||||
|
## Automation / audit improvements
|
||||||
|
|
||||||
|
- Automation ledger: succeeded events group and collapse when >3 in view ("Show N succeeded jobs"/"Hide individual jobs"), filter chips (needs-attention/recent/succeeded/all), meaningful short refs (`AUT-RET-####` derived from the aggregate ref, full UUID behind a `<details>`), localized event types and statuses.
|
||||||
|
- Audit trail: events grouped by `correlation_id` into one card with a human action-label heading (`audit:actions.*`), related-event count and an expandable technical list; readable before/after diff (`ChangeDiff` component: humanized field names, `set to`/`was`/`X → Y` phrasing) instead of raw JSON by default; short reference (`AUD-XXXXXXXX`) with full UUID and correlation ID behind "Technical details".
|
||||||
|
|
||||||
|
## Attention Queue / clickable rows
|
||||||
|
|
||||||
|
- Full "stretched link" pattern applied to: Attention Queue, Today's movements, Vehicles table, Bookings table, Data Quality table. Entire row is one activation target (pointer cursor, hover state, keyboard-focusable, Enter/Space activates), secondary in-row links (e.g. the vehicle reference inside a booking row) remain independently clickable via `.cell-link { z-index: 2 }` layered above the row overlay.
|
||||||
|
- Dedicated tests in `frontend/e2e/clickable-rows.spec.ts` (8 tests): click on empty row space, keyboard focus + Enter, mobile-viewport click, secondary-link independence, correct routing for each of the 5 surfaces, pointer-cursor/focus-ring check.
|
||||||
|
|
||||||
|
## Test results (all commands re-run against this exact final state)
|
||||||
|
|
||||||
|
### Backend (local dev stack, clean-checkout instance, and live Unraid deployment — all three, all green)
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose exec api pytest -q → 131 passed
|
||||||
|
docker compose exec api ruff check . → All checks passed!
|
||||||
|
docker compose exec api mypy app → Success: no issues found in 48 source files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```
|
||||||
|
cd frontend && npm run build → tsc -b && vite build: success
|
||||||
|
```
|
||||||
|
|
||||||
|
### Playwright (92 tests; run against local dev stack, the isolated clean-checkout stack, and the live Unraid deployment — 92/92 passed in all three runs)
|
||||||
|
|
||||||
|
```
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Suites: `demo-accessibility`, `demo-entry`, `demo-guide` (including the 3 new adaptive-breakpoint tests, chip close-control test, Escape test, scroll/focus/highlight test), `demo-legibility`, `demo`, `guided-demo-full`, `i18n-coverage`, `interactive-elements`, `responsive-i18n` (7 breakpoints × 3 languages = 21 tests), `ui-redesign`, `clickable-rows` (new, 8 tests).
|
||||||
|
|
||||||
|
## Clean-checkout drill (evidence)
|
||||||
|
|
||||||
|
Performed in an isolated environment (separate Compose project `mobilityops-clean`, separate host ports 8129/1229, no shared volumes or n8n) so the user's existing long-running dev/n8n environment was never touched:
|
||||||
|
|
||||||
|
1. `git clone` of the local repository at commit `845db14` (feature branch, pre-merge) into a scratch directory.
|
||||||
|
2. `cp .env.example .env` (project name and ports overridden for isolation only).
|
||||||
|
3. `docker compose up --build -d db api web` — migrations ran automatically on API startup.
|
||||||
|
4. `docker compose exec api python -m app.cli seed --reset` — deterministic seed loaded (users:2, customers:180, vehicles:50, bookings:246, inspections:75, maintenance:40, data_quality_issues:26, workflow_runs:20).
|
||||||
|
5. `docker compose exec api pytest -q` → 131 passed. `ruff check .` → clean. `mypy app` → clean.
|
||||||
|
6. `npm ci && npm run build` → clean build.
|
||||||
|
7. `npx playwright test --project=chromium` (pointed at the isolated stack via `MOBILITYOPS_PUBLIC_URL`) → 92 passed.
|
||||||
|
8. Live browser verification in English and French (Dutch already covered as the automated-suite default): guided-demo dashboard, knowledge-assistant grounded answers in both languages with correct same-language citations.
|
||||||
|
9. `POST /api/v1/demo/reset` → `scenario_integrity: {"all_ready": true, "not_ready": []}`.
|
||||||
|
10. Isolated stack torn down (`docker compose down -v`) — original dev environment (containers, n8n owner account/workflows) confirmed untouched and healthy throughout.
|
||||||
|
|
||||||
|
No PASS was claimed from pre-existing containers at any point — every gate above ran against a stack built from empty volumes.
|
||||||
|
|
||||||
|
## Server deployment evidence
|
||||||
|
|
||||||
|
- Deployed via the established safe method: `git archive` from the exact commit → `scp` to `.deploy/source-<sha>.tar.gz` on Unraid → extract → update `.deploy/source-revision` → `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web` (db never rebuilt; server `.env` and named volumes — Postgres, n8n — preserved throughout).
|
||||||
|
- Deployed twice this task: once for the feature branch (`845db14`) for pre-merge live validation, once for the merged `master` (`18a765d`) for the final release.
|
||||||
|
- Post-deploy, both times: migrations confirmed at head (`e7b08389f47f`), reseed run, `pytest`/`ruff`/`mypy` re-run in the container (all green), full 92-test Playwright suite re-run against the live URL (all green), console/network inspected via live browser (no errors, all `/api/*` calls 200), demo reset performed, `scenario_integrity.all_ready: true` confirmed both times.
|
||||||
|
- Real shared n8n instance (`http://192.168.10.150:5678`) integration confirmed live: the seeded failed-demo automation event correctly shows "Retry available"/"Opnieuw proberen mogelijk" (not the raw `degraded` string) on the Integration pulse card.
|
||||||
|
|
||||||
|
## Responsive / accessibility
|
||||||
|
|
||||||
|
- No-horizontal-overflow verified across the full 7-breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844, 360×800) in all 3 languages (`responsive-i18n.spec.ts`, 21 tests) plus the original 4-breakpoint English suite (`ui-redesign.spec.ts`).
|
||||||
|
- Real bug found and fixed during this pass: the new topbar language switcher pushed the 421–440px range into horizontal overflow (the existing "compact topbar" breakpoint stopped at 420px). Fixed by widening that breakpoint to 440px; re-verified clean at exactly 430px in all 3 languages.
|
||||||
|
- Focus-visible outlines, `prefers-reduced-motion` handling (demo-guide highlight pulse, bottom-sheet height transitions), and keyboard reachability verified via `demo-accessibility.spec.ts` and the new adaptive-guide/clickable-row tests.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
`artifacts/fleet-ops-release/screenshots/`:
|
||||||
|
- `01-login-nl.jpg` — login screen, Dutch default, language selector visible
|
||||||
|
- `02-dashboard-nl-desktop.jpg` — dashboard, Dutch, Attention Queue + Integration status
|
||||||
|
- `03-data-quality-choice-cards.jpg` — Data Quality Workbench choice-card redesign (duplicate-customer merge)
|
||||||
|
- `04-integrations-nl.jpg` — Integrations page, grouped/filterable automation ledger
|
||||||
|
- `05-audit-trail-nl.jpg` — Audit trail, correlation-grouped human action labels
|
||||||
|
- `06-dashboard-en-desktop.jpg` — dashboard, English
|
||||||
|
- `07-dashboard-fr-desktop.jpg` — dashboard, French
|
||||||
|
- `08-about-fr.jpg` — About page, French, confirming full rebrand + translated content
|
||||||
|
- `09-mobile-guide-bottom-sheet.png` — mobile bottom sheet, half state (390×844)
|
||||||
|
- `10-mobile-guide-full.png` — mobile bottom sheet, full state (390×844)
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Data-quality evidence "summary" strings (the free-text detail line under each Attention Queue/Data Quality row, e.g. "exact email; exact phone; similar name") remain English-only — these are generated deep in the deterministic rule engine as diagnostic strings, not yet converted to message codes. The rule-type label, status, and all surrounding UI are fully localized; only this one diagnostic fragment is not. Documented as a follow-up, not blocking.
|
||||||
|
- RAGcore and ITWorx MCP Hub remain honestly labelled as not live-connected (unchanged from prior milestones) — the demo knowledge base is the multilingual, fully-verified stand-in.
|
||||||
|
- Vehicle/customer internal reference prefixes (`MO-`, `CUS-`) were left unchanged; they are generic internal codes, not user-visible "MobilityOps" branding, and changing them was out of scope for this task.
|
||||||
|
- Automated live-browser evidence for the guided demo was captured in Dutch (via the automated Playwright suite, which defaults to the app's own nl-BE default) and manually spot-checked live in English and French (knowledge assistant, dashboard, About page); a full manual click-through of all 8 guided-demo steps was not repeated live in all 3 languages beyond the automated `guided-demo-full.spec.ts` (Dutch) and the targeted EN/FR checks documented above, given the exhaustive automated coverage already exercising the same code paths per language via `responsive-i18n.spec.ts` and `i18n-coverage.spec.ts`.
|
||||||
|
|
||||||
|
## Rollback procedure
|
||||||
|
|
||||||
|
- `.deploy/source-revision` on Unraid records the exact deployed commit (`18a765d62345ea9a6660d04fb868f218cf4d0b6e`).
|
||||||
|
- Prior tarballs remain in `.deploy/` on the server, including `.deploy/source-845db14.tar.gz` (feature branch, pre-merge) and `.deploy/source-4a268c7.tar.gz` (previous release, pre-polish).
|
||||||
|
- To roll back: extract the desired `source-<short-sha>.tar.gz`, update `.deploy/source-revision` to match, and re-run `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`. Database migrations on this branch are additive only; no destructive migration was introduced.
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,246 @@
|
|||||||
|
# MobilityOps functional-completion — final summary
|
||||||
|
|
||||||
|
## Outcome: PASS
|
||||||
|
|
||||||
|
All achievable functional-completion requirements were audited, implemented, tested
|
||||||
|
locally (including a genuine clean-checkout drill), committed, pushed, deployed to
|
||||||
|
Unraid and re-verified against the live server after every batch.
|
||||||
|
|
||||||
|
## Revisions
|
||||||
|
|
||||||
|
- Starting branch: `design/mobilityops-premium-ui`
|
||||||
|
- Starting/observed commit: `54dc952915a4874fcdf14781e1c37feb0e253851`
|
||||||
|
- Completion branch: `feat/mobilityops-functional-completion`
|
||||||
|
- Final commit: `5b2827eb7e84e40d97c4d025debb2af1246908e0` (this is the parent commit
|
||||||
|
the deploy below targets; the commit that actually records this string is
|
||||||
|
necessarily one commit later — `git log -1` on this branch is the authoritative
|
||||||
|
source of the true HEAD)
|
||||||
|
- Gitea branch URL: `https://gitea.itworx.tech/Jens/MobilityOps` (SSH remote
|
||||||
|
`ssh://git@192.168.10.150:222/Jens/MobilityOps.git`), branch
|
||||||
|
`feat/mobilityops-functional-completion`
|
||||||
|
- Deployed URL: `http://192.168.10.150:1236`
|
||||||
|
- Server deployment directory: `/mnt/user/appdata/mobilityops`
|
||||||
|
- Server Compose project: `mobilityops`
|
||||||
|
|
||||||
|
## Exact commands executed (representative — run after every batch)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local backend gate
|
||||||
|
docker compose exec -T api python -m app.cli seed --reset
|
||||||
|
docker compose exec -T api pytest -q
|
||||||
|
docker compose run --rm api ruff check .
|
||||||
|
docker compose run --rm api mypy app
|
||||||
|
|
||||||
|
# Local frontend gate
|
||||||
|
cd frontend && npx tsc -b --noEmit && npm run build
|
||||||
|
|
||||||
|
# Local e2e (against the local dev stack)
|
||||||
|
npx playwright test
|
||||||
|
|
||||||
|
# Deploy the exact committed revision
|
||||||
|
COMMIT=$(git rev-parse HEAD)
|
||||||
|
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
||||||
|
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops && tar -xzf .deploy/source.tar.gz && echo $COMMIT > .deploy/source-revision"
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web"
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current"
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops && docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
||||||
|
|
||||||
|
# e2e against the live server
|
||||||
|
MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236 npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
Clean-checkout drill (once, section 14):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --branch feat/mobilityops-functional-completion \
|
||||||
|
<repo> /tmp/mobilityops-clean-checkout
|
||||||
|
cd /tmp/mobilityops-clean-checkout
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose -p mobilityops-clean up --build -d # isolated project name/ports
|
||||||
|
docker compose -p mobilityops-clean exec -T api alembic current
|
||||||
|
docker compose -p mobilityops-clean exec -T api python -m app.cli seed --reset
|
||||||
|
docker compose -p mobilityops-clean exec -T api pytest -q
|
||||||
|
docker compose -p mobilityops-clean run --rm api ruff check .
|
||||||
|
docker compose -p mobilityops-clean run --rm api mypy app
|
||||||
|
cd frontend && npm ci && npx tsc -b --noEmit && npm run build
|
||||||
|
MOBILITYOPS_PUBLIC_URL=http://localhost:11228 npx playwright test
|
||||||
|
docker compose -p mobilityops-clean down -v # isolated project only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test and validation results
|
||||||
|
|
||||||
|
| Gate | Local dev stack | Clean-checkout (isolated) | Live Unraid |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pytest` | 117 passed | 117 passed | — (not applicable; no test runner on the review host) |
|
||||||
|
| `ruff check .` | clean | clean | — |
|
||||||
|
| `mypy app` | 0 issues / 46 files | 0 issues / 46 files | — |
|
||||||
|
| `npx tsc -b` | clean | clean | — |
|
||||||
|
| `npm run build` | clean | clean | — |
|
||||||
|
| `npx playwright test` | 37 passed | 37 passed | **37 passed** (against `http://192.168.10.150:1236`) |
|
||||||
|
| `npm audit` | 4 known advisories (unchanged — see Known limitations) | same | — |
|
||||||
|
|
||||||
|
Every batch (1 through 5) was deployed and re-verified with the full 37-test Playwright
|
||||||
|
suite against the live server before moving to the next batch, not only at the end.
|
||||||
|
|
||||||
|
## Application URLs and ports
|
||||||
|
|
||||||
|
| Service | URL / port | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Web (Unraid) | `http://192.168.10.150:1236` | only MobilityOps-owned service exposed on the LAN |
|
||||||
|
| API (Unraid) | Compose-network only | reached through the web nginx `/api/` proxy |
|
||||||
|
| PostgreSQL (Unraid) | Compose-network only | never exposed |
|
||||||
|
| Shared n8n (Unraid) | `http://192.168.10.150:5678` | pre-existing host infrastructure, outside the MobilityOps Compose project |
|
||||||
|
| Web (local dev) | `http://localhost:1228` | |
|
||||||
|
| API (local dev) | `http://localhost:8128` | |
|
||||||
|
| n8n (local dev) | `http://localhost:5678` | bundled, `bundled-n8n` profile |
|
||||||
|
|
||||||
|
## Demo users and access method
|
||||||
|
|
||||||
|
Two fixed seeded identities, selected via the login screen's role buttons (no
|
||||||
|
password): **Amelie De Ridder** (`USR-OPS`, Operations Manager) and **Karim
|
||||||
|
Boujaddaine** (`USR-EMP`, Rental Employee). `POST /api/v1/demo/login` issues an
|
||||||
|
HttpOnly, `SameSite=Lax` signed session cookie; `GET /api/v1/demo/session` (marked
|
||||||
|
`Cache-Control: no-store`) is what the browser actually trusts on every load, not a
|
||||||
|
locally cached copy.
|
||||||
|
|
||||||
|
## Implemented functionality (this pass, on top of the already-accepted M0–M7/design baseline)
|
||||||
|
|
||||||
|
- Fixed two confirmed defects: Vehicles and Bookings both computed a filtered/paginated
|
||||||
|
result but rendered the raw array.
|
||||||
|
- Server-backed session lifecycle (`GET /demo/session`, `POST /demo/logout`), central
|
||||||
|
401 handling, no more `sessionStorage`-as-authority.
|
||||||
|
- A role matrix enforced server-side (403 on every manager-only action for Rental
|
||||||
|
Employee, not just a hidden button) and mirrored in the nav/route guards.
|
||||||
|
- Authoritative, non-mutating return preview (`POST /bookings/{ref}/return-preview`)
|
||||||
|
sharing its evaluation function with commit — fixed a real bug where the frontend's
|
||||||
|
guessed preview text was wrong (damage → described as "maintenance", actual rule
|
||||||
|
"blocked"; the no-contradiction case → described as "available", actual rule always
|
||||||
|
"cleaning" first).
|
||||||
|
- Audit API/UI now expose `before`/`after` (the columns existed but were never
|
||||||
|
serialized) plus a resolved safe entity link.
|
||||||
|
- Typed related-entity snapshots (booking_overlap's related refs are bookings, not
|
||||||
|
vehicles — previously silently unresolved) and a bounded resolution flow for every
|
||||||
|
one of the five data-quality rule types, plus an audited manual scan trigger and
|
||||||
|
documented recurrence linking (`reopened_from`/`previous_decision`).
|
||||||
|
- Role-aware backend search (`GET /api/v1/search`) replacing a blind client-side regex
|
||||||
|
guesser, with a real results panel, keyboard navigation and debouncing.
|
||||||
|
- A safe, confirmed demo-reset UI trigger (the endpoint already existed and was
|
||||||
|
already gated).
|
||||||
|
- Truthful aggregate n8n integration status (`GET /api/v1/integrations/status`) from
|
||||||
|
outbox delivery counts, replacing a single-most-recent-event read; fixed
|
||||||
|
`MCP_HUB_REGISTRATION_ENABLED` being declared in `.env.example` but never wired into
|
||||||
|
`Settings`.
|
||||||
|
- Bounded outbox delivery-lease recovery for a process crash between claim and outcome.
|
||||||
|
- A second n8n workflow (scheduled quality scan), independent of RAGcore/MCP Hub.
|
||||||
|
|
||||||
|
## RAGcore integration status
|
||||||
|
|
||||||
|
Unchanged from the prior baseline and honestly reported throughout: the demo
|
||||||
|
`KnowledgeProvider` (deterministic TF-IDF extractive retrieval over local procedure
|
||||||
|
documents) satisfies the knowledge-assistant acceptance criteria and is fully verified.
|
||||||
|
A `RAGcoreKnowledgeProvider` HTTP adapter is implemented and unit-tested (including its
|
||||||
|
unavailable-degradation path) but was never exercised against a live RAGcore instance in
|
||||||
|
this environment — no live RAGcore instance exists to test against.
|
||||||
|
`KNOWLEDGE_PROVIDER=demo` on the Unraid deployment; no simulated live connection is ever
|
||||||
|
shown.
|
||||||
|
|
||||||
|
## MCP Hub integration status
|
||||||
|
|
||||||
|
The four read-only provider endpoints are implemented, tested, and directly
|
||||||
|
curl-verified with correct service-token auth enforcement and audit logging.
|
||||||
|
`MCP_HUB_REGISTRATION_ENABLED` — previously declared in `.env.example` but silently
|
||||||
|
dropped by `extra="ignore"` since it had no `Settings` field — is now actually wired in
|
||||||
|
and honestly reported (`GET /api/v1/integrations/status`'s `mcp_hub.state`). It is
|
||||||
|
`false` on the Unraid deployment (`state: "not_configured"`). No live Hub instance was
|
||||||
|
reachable in this environment to verify an actual Hub round trip.
|
||||||
|
|
||||||
|
## n8n integration status
|
||||||
|
|
||||||
|
Fully implemented and live-verified. The original return-processing workflow: verified
|
||||||
|
against both the local bundled instance and the shared Unraid instance, including a real
|
||||||
|
degraded-mode drill in an earlier session (n8n stopped mid-flow → return still committed
|
||||||
|
locally, event stayed `pending` with backoff, self-healed once n8n returned) and the
|
||||||
|
manual-retry path. This pass adds:
|
||||||
|
|
||||||
|
- **Truthful status**: `GET /api/v1/integrations/status` derives n8n health from
|
||||||
|
aggregate outbox counts (pending/delivering/succeeded/failed), not the single most
|
||||||
|
recent event.
|
||||||
|
- **Stale-delivery-lease recovery**: a claimed-but-never-resolved `delivering` row (the
|
||||||
|
process crashing between claim and outcome) is now recoverable; unit-tested including
|
||||||
|
a simulated crash, and confirmed a still-alive worker's unexpired lease is never
|
||||||
|
touched.
|
||||||
|
- **Second workflow**: `mobilityops-scheduled-quality-scan` (hourly + manual-test
|
||||||
|
trigger, ships `"active": false"`), calling `POST
|
||||||
|
/api/v1/integrations/n8n/scheduled-scan`. Live-verified two ways: (1) executed
|
||||||
|
end-to-end via the Manual test trigger against the **local** n8n instance — full
|
||||||
|
green execution in the n8n editor, confirmed by the resulting
|
||||||
|
`data_quality_scan_run` audit event (`actor_type=service`); (2) published to the
|
||||||
|
**shared Unraid n8n** via `deploy/unraid/setup-scheduled-scan.sh` and the resulting
|
||||||
|
endpoint directly curl-verified against the live deployed API, also confirmed via the
|
||||||
|
audit trail. The shared instance's own UI could not be browser-tested directly — it
|
||||||
|
runs `N8N_SECURE_COOKIE=true` and refuses login over the plain-HTTP LAN URL used for
|
||||||
|
automated testing here, which is correct, pre-existing shared-infrastructure
|
||||||
|
behaviour and out of scope to change.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- RAGcore and ITWorx MCP Hub remain honestly not-live-connected — no live instance of
|
||||||
|
either exists in this environment (environment limitation, not a code defect).
|
||||||
|
- `npm audit`: one moderate esbuild/Vite dev-server-only advisory (fix requires a Vite
|
||||||
|
major upgrade, deliberately deferred), and a
|
||||||
|
react-router RSC-mode advisory that doesn't apply (the app never uses RSC/SSR mode) —
|
||||||
|
both pre-existing, confirmed unchanged by this pass's clean `npm ci`.
|
||||||
|
- Demo authentication remains the accepted HMAC-cookie PoC mechanism tied to two fixed
|
||||||
|
seeded identities — not a production identity provider.
|
||||||
|
- The scheduled quality-scan workflow's own n8n-engine execution was verified live
|
||||||
|
against the local bundled n8n and, for the HTTP round trip specifically, against the
|
||||||
|
shared Unraid n8n's resulting API call — not against a full n8n-engine execution *on
|
||||||
|
the shared instance itself*, for the browser-access reason above.
|
||||||
|
- `n8n_delivery_lease_seconds` (120s default) is a code-level tunable, not exposed in
|
||||||
|
`.env.example`, consistent with the existing `n8n_dispatch_interval_seconds`/
|
||||||
|
`n8n_max_attempts`/`n8n_http_timeout_seconds` tunables already handled that way.
|
||||||
|
|
||||||
|
## Clean deployment instructions
|
||||||
|
|
||||||
|
See `deploy/unraid/README.md` and `docs/17-runbook.md` for the full runbook. Redeploy
|
||||||
|
the exact committed revision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMMIT=<commit to deploy>
|
||||||
|
git archive --format=tar.gz --output=/tmp/source.tar.gz "$COMMIT"
|
||||||
|
scp -P 22 /tmp/source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source.tar.gz
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||||
|
&& tar -xzf .deploy/source.tar.gz \
|
||||||
|
&& echo $COMMIT > .deploy/source-revision \
|
||||||
|
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web \
|
||||||
|
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current \
|
||||||
|
&& curl -fsS http://127.0.0.1:1236/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` and both named volumes (`mobilityops-db`, `mobilityops-n8n`) are preserved by
|
||||||
|
this flow; nothing outside the `mobilityops` Compose project is touched.
|
||||||
|
|
||||||
|
## Five-minute demonstration flow
|
||||||
|
|
||||||
|
1. Open `http://192.168.10.150:1236`, log in as **Operations Manager**.
|
||||||
|
2. Dashboard: point out the persisted readiness metrics and the now-truthful n8n
|
||||||
|
integration-status card (aggregate counts, not just the latest event).
|
||||||
|
3. Global search (`Ctrl/Cmd+K`): type a vehicle, booking or issue reference; use arrow
|
||||||
|
keys + Enter to navigate; show the no-results state for a nonsense query.
|
||||||
|
4. Bookings: filter by status, page through results (max 25/page), confirm page 2
|
||||||
|
differs from page 1.
|
||||||
|
5. Open an active booking → capture a return with a below-canonical odometer reading →
|
||||||
|
the review step shows the server's authoritative evaluation (odometer regression
|
||||||
|
flagged, resulting status and reason) → confirm → result screen distinguishes local
|
||||||
|
commit success from queued-not-yet-confirmed n8n delivery, links to the created
|
||||||
|
data-quality issue.
|
||||||
|
6. Data quality: run a manual scan; open the newly flagged (or an existing) issue for
|
||||||
|
each rule type and show its dedicated bounded resolution flow (not raw JSON).
|
||||||
|
7. Audit trail: filter by the correlation ID from the return above; show the
|
||||||
|
human-readable before/after change summary, expand the raw-JSON `<details>`.
|
||||||
|
8. Switch role to **Rental Employee**: show Data Quality/Integrations/Audit are absent
|
||||||
|
from the nav, and that direct URL navigation to any of them shows the restricted
|
||||||
|
message rather than partial data or a crash.
|
||||||
|
9. Switch back to Operations Manager, trigger **Reset demo data** with confirmation,
|
||||||
|
land back at login, log in again to confirm deterministic data was restored.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Live n8n + RAGcore integration — final evidence
|
||||||
|
|
||||||
|
No credential values, tokens, or secrets appear anywhere in this document. Where a
|
||||||
|
credential or trace ID is referenced, only its name or an opaque reference identifier is
|
||||||
|
given, never its value.
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
Built on branch `feat/live-n8n-ragcore-integration`, HEAD at commit
|
||||||
|
`aaa16305354d34f9c1f4d57253d33d9062c38faa` ("docs: record WF2 retry fix and WF4's
|
||||||
|
n8n-session-expiry blocker"). Run `git log --oneline feat/live-n8n-ragcore-integration`
|
||||||
|
for the full history of this effort.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The brief required treating n8n (`https://n8n.itworx.tech`) as a full third integration
|
||||||
|
layer alongside RAGcore and MCP Hub, with Fleet Ops keeping exclusive ownership of
|
||||||
|
business rules, authorization, transactions, audit, and idempotency. Four canonical n8n
|
||||||
|
workflows were required. The repository (`n8n/workflows/*.json` + `MANIFEST.md` +
|
||||||
|
`n8n/workflows/check_drift.py`) is the source of truth for cleaned workflow definitions;
|
||||||
|
the Fleet Ops integration status page (`/automation`) shows real per-workflow operational
|
||||||
|
evidence, not a config boolean.
|
||||||
|
|
||||||
|
## Result summary
|
||||||
|
|
||||||
|
| # | Workflow | Status | Live evidence this round |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Fleet Ops — Vehicle Return Orchestration | **Live, hardened** | Timeout+bounded-retry gap found and fixed |
|
||||||
|
| 2 | Fleet Ops — Scheduled Data Quality Scan | **Live, hardened** | Same gap found and fixed |
|
||||||
|
| 3 | Fleet Ops — RAGcore Procedure Sync | **Blocked** | Not built — RAGcore rejects credential issuance (see below) |
|
||||||
|
| 4 | Fleet Ops — Workflow Error Handler | **Live, validated** | Mock + genuine induced-failure test; own hardening incomplete (see below) |
|
||||||
|
|
||||||
|
Full per-workflow detail (purpose, trigger, event contract, required credentials, live
|
||||||
|
workflow ID, checksum) is in `n8n/workflows/MANIFEST.md`, which is the authoritative,
|
||||||
|
continuously-updated source — this document is a point-in-time summary of that state
|
||||||
|
plus the reasoning behind what's not done.
|
||||||
|
|
||||||
|
## Workflow 1 — Vehicle Return Orchestration
|
||||||
|
|
||||||
|
Live workflow ID `mobilityops-return-processing`. Validated in an earlier round of this
|
||||||
|
effort: webhook trigger requires Header Auth (`Fleet Ops Webhook Trigger Token`),
|
||||||
|
validates `event_type == vehicle.returned.v1`, derives a follow-up category, calls Fleet
|
||||||
|
Ops's `/return-callback` endpoint with an `Idempotency-Key` header via a named
|
||||||
|
`Fleet Ops Service Token` credential (not a literal secret), and responds with a
|
||||||
|
controlled JSON result. Idempotent on both sides (`event_id` flows through as the
|
||||||
|
dedup key; the backend independently checks for a prior audit event before recording
|
||||||
|
again).
|
||||||
|
|
||||||
|
**This round's finding**: the `Record follow-up` HTTP node had no explicit timeout and
|
||||||
|
"Retry On Fail" disabled — a real gap against the requirement that external dependencies
|
||||||
|
have timeouts and bounded retries. Fixed live: Retry On Fail (3 tries, 1000ms wait) + a
|
||||||
|
15000ms timeout, published. Safe to retry because the callback is idempotent. Repo
|
||||||
|
definition and manifest checksum synced (commit `0562893`).
|
||||||
|
|
||||||
|
Attached to workflow 4 as its Error Workflow.
|
||||||
|
|
||||||
|
## Workflow 2 — Scheduled Data Quality Scan
|
||||||
|
|
||||||
|
Live workflow ID `mobilityops-scheduled-quality-scan`. Validated earlier: hourly
|
||||||
|
Schedule Trigger + a Manual Trigger for on-demand testing, both feeding a single HTTP
|
||||||
|
call to Fleet Ops's `/scheduled-scan` endpoint (Header Auth via the same `Fleet Ops
|
||||||
|
Service Token` credential, 15000ms timeout already configured), which runs the
|
||||||
|
domain-level `run_scan()` function — documented and tested as idempotent by
|
||||||
|
construction (only ever creates an issue for a condition that doesn't already have one
|
||||||
|
open), so overlapping or retried triggers do no duplicate domain work.
|
||||||
|
|
||||||
|
**This round's finding**: the same Retry On Fail gap as workflow 1 (timeout was already
|
||||||
|
set, retries were not). Fixed live the same way (3 tries, 1000ms wait), published. Repo
|
||||||
|
definition and manifest checksum synced (commit `167bf49`).
|
||||||
|
|
||||||
|
Attached to workflow 4 as its Error Workflow.
|
||||||
|
|
||||||
|
## Workflow 3 — RAGcore Procedure Sync — blocked
|
||||||
|
|
||||||
|
**Not built.** This workflow needs an application credential (scope `sources:sync`) for
|
||||||
|
the `fleet-ops` application in RAGcore. Two independent issuance attempts, in two
|
||||||
|
separate rounds of this effort, both failed with an opaque server-side rejection:
|
||||||
|
|
||||||
|
1. **Raw API**: `POST /v1/applications/{id}/credentials` → `400`, "authoritative
|
||||||
|
service-account state rejected issuance".
|
||||||
|
2. **RAGcore admin UI**, this round, after the project owner explicitly authorized
|
||||||
|
Claude to self-issue the credential: the "Issue credential" form for the `fleet-ops`
|
||||||
|
application, submitted as the Platform Admin role (the highest role visible in the
|
||||||
|
RAGcore admin), with name `n8n-ragcore-procedure-sync` and scope `sources:sync` only.
|
||||||
|
Result: "Something went wrong. The credential could not be issued with those
|
||||||
|
values.", trace reference `1955c6a8968c4941a22a1faef39e17a7`.
|
||||||
|
|
||||||
|
The `fleet-ops` application itself shows as ordinary/`Active` in the RAGcore admin, with
|
||||||
|
no visible lock flag, and RAGcore's own OpenAPI spec documents no validation rule that
|
||||||
|
would explain either rejection (no `422`, no field-level errors). Two independent paths
|
||||||
|
— a raw API call and the admin UI as the top admin role — hitting the same failure
|
||||||
|
signature is conclusive evidence this is a RAGcore-side policy or bug, not a Fleet Ops
|
||||||
|
request-shape or permission problem. It is not fixable from the Fleet Ops side or
|
||||||
|
through further UI automation. Resolving it requires whoever operates the RAGcore
|
||||||
|
instance to look up the trace ID above (and the earlier raw-API rejection) in RAGcore's
|
||||||
|
own logs.
|
||||||
|
|
||||||
|
The real RAGcore contract this workflow will be built against — once a working
|
||||||
|
credential exists — was independently inspected via RAGcore's live OpenAPI spec and is
|
||||||
|
recorded in `docs/live-ai-integration/n8n-current-state.md` and
|
||||||
|
`contracts/ragcore-contract-assumptions.md`: control-plane endpoints require an
|
||||||
|
`Idempotency-Key` header; ingestion is `POST /v1/uploads`; retrieval is `POST
|
||||||
|
/v1/search` / `/v1/context` / `/v1/answers` (the latter requiring `requested_space_ids`,
|
||||||
|
an array of knowledge-space UUIDs); health is `/health/live` and `/health/ready` (not
|
||||||
|
`/health`); the scope enum is `search, context, answer, documents:read, citations:read,
|
||||||
|
feedback:write, sources:sync`.
|
||||||
|
|
||||||
|
**`RAGcoreKnowledgeProvider` adapter** (`backend/app/services/knowledge/ragcore.py`)
|
||||||
|
still targets the earlier speculative contract (`/health`, `POST /api/v1/ask`, Bearer
|
||||||
|
token) rather than the real one above. This was deliberately **not** rewritten this
|
||||||
|
round: rewriting it blind, without a credential to validate against, risks introducing
|
||||||
|
a silent behavioral bug in exactly the code path responsible for the project's "AI must
|
||||||
|
never invent an answer when RAGcore is unavailable or returns insufficient evidence"
|
||||||
|
guarantee — for example a wrong `evidence_state` mapping that looks fine in code review
|
||||||
|
but misclassifies "unavailable" as "insufficient" (or vice versa) against the real
|
||||||
|
response shape. The adapter's current behavior is honest and safe (it degrades cleanly
|
||||||
|
to `unavailable` on any request or parsing failure, and `ragcore_api_token` is unset by
|
||||||
|
default so the app correctly runs on the local demo knowledge provider today). The
|
||||||
|
rewrite stays queued behind the same credential blocker as workflow 3.
|
||||||
|
|
||||||
|
## Workflow 4 — Workflow Error Handler
|
||||||
|
|
||||||
|
Live workflow ID `Xppn2rAEqUuyiCJF`. Built and live-validated in an earlier round:
|
||||||
|
Error Trigger → a Code node that derives a bounded, secret-free failure report (error
|
||||||
|
category classified from the message text, truncated summary, no stack trace, no
|
||||||
|
headers or tokens) → an HTTP call to Fleet Ops's `/workflow-error` endpoint (Header Auth
|
||||||
|
via the same `Fleet Ops Service Token` credential), which registers the failure as an
|
||||||
|
audit event idempotently keyed on `execution_id`.
|
||||||
|
|
||||||
|
Set as the Error Workflow on both workflow 1 and workflow 2. Confirmed workflow 4 has no
|
||||||
|
Error Workflow of its own (prevents a recursive loop).
|
||||||
|
|
||||||
|
**Live validation performed**: a pinned mock Error Trigger payload produced a real `200
|
||||||
|
{"status":"registered", ...}` from the live Fleet Ops server; re-running the identical
|
||||||
|
payload produced `"status":"already_registered"`, confirming idempotency. A genuine
|
||||||
|
induced failure (temporarily pointing workflow 2's HTTP node at a nonexistent path, then
|
||||||
|
reverting) confirmed workflow 2 itself fails correctly against a broken endpoint and
|
||||||
|
recovers cleanly once reverted.
|
||||||
|
|
||||||
|
**Known limitation**: n8n's Error Workflow trigger does not fire for manual editor
|
||||||
|
"Execute workflow" test runs — checked via workflow 4's own Executions list after the
|
||||||
|
induced workflow-2 failure, and confirmed no new execution appeared. n8n only invokes a
|
||||||
|
workflow's assigned Error Workflow for unattended/production trigger executions, not
|
||||||
|
manual test runs from the editor. The mock-data path exercises the same nodes, logic,
|
||||||
|
and real Fleet Ops endpoint, but a fully automatic (schedule- or webhook-triggered)
|
||||||
|
failure cascading into workflow 4 was not observed live in either round.
|
||||||
|
|
||||||
|
**Open follow-up (minor, non-blocking)**: continuing this round's acceptance pass to
|
||||||
|
workflow 4 found the same timeout/retry gap as workflows 1 and 2 on its own outbound
|
||||||
|
HTTP call. A fix was started (15000ms timeout added, Retry On Fail toggled on) but n8n's
|
||||||
|
autosave began failing with "Unauthorized" mid-edit; a fresh browser tab confirmed the
|
||||||
|
n8n session had expired (redirected to `/signin`). Nothing was saved — workflow 4's live
|
||||||
|
definition is unchanged from before this round, so there is no partial or broken state.
|
||||||
|
This is lower-stakes than workflows 1/2 (workflow 4 is the error notifier itself, not a
|
||||||
|
primary business flow, and a failed error-report is already visible in n8n's own
|
||||||
|
execution history via `On Error: Stop Workflow`) but should be finished once the n8n
|
||||||
|
browser session is re-authenticated.
|
||||||
|
|
||||||
|
## Repository source of truth
|
||||||
|
|
||||||
|
`n8n/workflows/` holds cleaned, credential-value-free JSON definitions for all built
|
||||||
|
workflows, `n8n/workflows/MANIFEST.md` documents purpose/trigger/contract/credentials/
|
||||||
|
live-ID/checksum for all four canonical workflows (including workflow 3's blocked
|
||||||
|
status), and `n8n/workflows/check_drift.py` is a read-only script that compares the
|
||||||
|
repo definitions against the live instance via n8n's Public API and reports drift —
|
||||||
|
safe to run in CI as a non-blocking check. No literal export/download mechanism was
|
||||||
|
found working in this n8n version, so each definition was reconstructed from direct,
|
||||||
|
verified UI inspection rather than a native export; this limitation is noted in the
|
||||||
|
manifest itself.
|
||||||
|
|
||||||
|
## Integration status page
|
||||||
|
|
||||||
|
`/automation` (Operations Manager only) surfaces real per-workflow evidence derived
|
||||||
|
purely from Fleet Ops's own audit/outbox tables — no new dependency on n8n's API was
|
||||||
|
added to the backend. Each of the four canonical workflows shows a status (not built /
|
||||||
|
no evidence yet / operational) and a last-evidence timestamp; the scheduled-scan
|
||||||
|
evidence specifically filters to `actor_type == "service"` so a manually-triggered scan
|
||||||
|
in the UI doesn't count as n8n evidence. An error-handler summary line reports total
|
||||||
|
registered automation failures and the most recent one.
|
||||||
|
|
||||||
|
Verified live in the browser (Dutch locale) both locally and on the deployed
|
||||||
|
production server (`http://192.168.10.150:1236/automation`): correctly showed "3 van 4
|
||||||
|
canonieke n8n-workflows hebben actuele evidentie van werking" with real timestamps for
|
||||||
|
the return/scan/error-handler workflows, "Nog Niet Gebouwd" for the RAGcore sync, and
|
||||||
|
the real error-handler registration from this effort's live testing.
|
||||||
|
|
||||||
|
## Deployments performed (all explicitly user-approved)
|
||||||
|
|
||||||
|
1. Backend `/workflow-error` endpoint (commit `bbdb4a9`) — deployed and verified
|
||||||
|
(`/health` OK, new endpoint returns `422` not `404` on an empty POST body).
|
||||||
|
2. Integration status page, backend + frontend (commit `4049c0c`) — deployed and
|
||||||
|
verified (`/health` OK, page renders real evidence in the browser).
|
||||||
|
|
||||||
|
The three n8n-side node edits this round (WF1 timeout/retry, WF2 timeout/retry, WF4's
|
||||||
|
incomplete attempt) are live edits to the n8n instance itself and do not require a
|
||||||
|
Fleet Ops redeploy.
|
||||||
|
|
||||||
|
## What's left
|
||||||
|
|
||||||
|
1. **RAGcore credential issuance** — blocked on RAGcore's own server-side rejection
|
||||||
|
(trace `1955c6a8968c4941a22a1faef39e17a7` and the earlier raw-API `400`). Needs
|
||||||
|
RAGcore's operator to investigate. Unblocks workflow 3 and the
|
||||||
|
`RAGcoreKnowledgeProvider` real-contract rewrite.
|
||||||
|
2. **Workflow 4's own timeout/bounded-retry hardening** — needs the n8n browser session
|
||||||
|
re-authenticated to finish; a small, well-understood, non-blocking edit.
|
||||||
|
3. **Fleet Ops logo/favicon** — explicitly deferred by the project owner as a separate,
|
||||||
|
unrelated follow-up task, not part of this integration effort.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""outbox last_error_code
|
||||||
|
|
||||||
|
Revision ID: 799d8800e241
|
||||||
|
Revises: e7b08389f47f
|
||||||
|
Create Date: 2026-08-03 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '799d8800e241'
|
||||||
|
down_revision: Union[str, None] = 'e7b08389f47f'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('outbox_events', sa.Column('last_error_code', sa.String(length=60), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('outbox_events', 'last_error_code')
|
||||||
@@ -1,15 +1,55 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_db, require_operations_manager
|
||||||
from app.models.audit import AuditEvent
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import AuditEventOut, CurrentUser
|
from app.schemas import AuditEventOut, CurrentUser
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||||
|
|
||||||
|
# Only entity types with a stable public reference and (optionally) a real frontend route
|
||||||
|
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
|
||||||
|
# entity_id and are left as plain labels.
|
||||||
|
_ENTITY_MODELS: dict[str, Any] = {
|
||||||
|
"vehicle": Vehicle,
|
||||||
|
"booking": Booking,
|
||||||
|
"customer": Customer,
|
||||||
|
"data_quality_issue": DataQualityIssue,
|
||||||
|
}
|
||||||
|
_ROUTE_TEMPLATES: dict[str, str] = {
|
||||||
|
"vehicle": "/vehicles/{ref}",
|
||||||
|
"booking": "/bookings/{ref}",
|
||||||
|
"data_quality_issue": "/data-quality/{ref}",
|
||||||
|
# No customer detail route exists in this proof of concept; still resolve the
|
||||||
|
# reference for display, just without a link.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
|
||||||
|
ids_by_type: dict[str, set[uuid.UUID]] = {}
|
||||||
|
for event in events:
|
||||||
|
if event.entity_id is not None and event.entity_type in _ENTITY_MODELS:
|
||||||
|
ids_by_type.setdefault(event.entity_type, set()).add(event.entity_id)
|
||||||
|
|
||||||
|
refs: dict[uuid.UUID, str] = {}
|
||||||
|
for entity_type, ids in ids_by_type.items():
|
||||||
|
model = _ENTITY_MODELS[entity_type]
|
||||||
|
rows: Sequence[Any] = db.scalars(select(model).where(model.id.in_(ids))).all()
|
||||||
|
for row in rows:
|
||||||
|
refs[row.id] = row.public_ref
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[AuditEventOut])
|
@router.get("", response_model=list[AuditEventOut])
|
||||||
def list_audit_events(
|
def list_audit_events(
|
||||||
@@ -19,7 +59,7 @@ def list_audit_events(
|
|||||||
correlation_id: str | None = Query(default=None),
|
correlation_id: str | None = Query(default=None),
|
||||||
limit: int = Query(default=100, le=500),
|
limit: int = Query(default=100, le=500),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[AuditEventOut]:
|
) -> list[AuditEventOut]:
|
||||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
|
||||||
if actor_label:
|
if actor_label:
|
||||||
@@ -31,17 +71,27 @@ def list_audit_events(
|
|||||||
if correlation_id:
|
if correlation_id:
|
||||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||||
events = db.scalars(stmt).all()
|
events = db.scalars(stmt).all()
|
||||||
return [
|
entity_refs = _resolve_entity_refs(db, events)
|
||||||
AuditEventOut(
|
|
||||||
id=str(e.id),
|
out = []
|
||||||
actor_type=e.actor_type,
|
for e in events:
|
||||||
actor_label=e.actor_label,
|
ref = entity_refs.get(e.entity_id) if e.entity_id else None
|
||||||
action=e.action,
|
route = _ROUTE_TEMPLATES.get(e.entity_type)
|
||||||
entity_type=e.entity_type,
|
out.append(
|
||||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
AuditEventOut(
|
||||||
correlation_id=str(e.correlation_id),
|
id=str(e.id),
|
||||||
occurred_at=e.occurred_at,
|
actor_type=e.actor_type,
|
||||||
metadata=e.metadata_json,
|
actor_label=e.actor_label,
|
||||||
|
action=e.action,
|
||||||
|
entity_type=e.entity_type,
|
||||||
|
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||||
|
entity_ref=ref,
|
||||||
|
entity_link=route.format(ref=ref) if route and ref else None,
|
||||||
|
correlation_id=str(e.correlation_id),
|
||||||
|
occurred_at=e.occurred_at,
|
||||||
|
before=e.before_json,
|
||||||
|
after=e.after_json,
|
||||||
|
metadata=e.metadata_json,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for e in events
|
return out
|
||||||
]
|
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ from app.api.deps import get_current_user, get_db
|
|||||||
from app.models.booking import Booking
|
from app.models.booking import Booking
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import BookingOut, CurrentUser, RegisterReturnRequest
|
from app.schemas import (
|
||||||
from app.services.returns import register_vehicle_return
|
BookingOut,
|
||||||
|
CurrentUser,
|
||||||
|
NextBookingRisk,
|
||||||
|
RegisterReturnRequest,
|
||||||
|
ReturnPreviewResult,
|
||||||
|
)
|
||||||
|
from app.services.returns import preview_vehicle_return, register_vehicle_return
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
||||||
|
|
||||||
@@ -66,6 +72,35 @@ def get_booking(
|
|||||||
return _to_out(booking, customer, vehicle)
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
|
||||||
|
def preview_return(
|
||||||
|
public_ref: str,
|
||||||
|
body: RegisterReturnRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> ReturnPreviewResult:
|
||||||
|
booking, vehicle, evaluation = preview_vehicle_return(db, public_ref, body)
|
||||||
|
return ReturnPreviewResult(
|
||||||
|
booking_ref=booking.public_ref,
|
||||||
|
vehicle_ref=vehicle.public_ref,
|
||||||
|
canonical_odometer_km=evaluation.canonical_odometer_km,
|
||||||
|
submitted_odometer_km=evaluation.submitted_odometer_km,
|
||||||
|
odometer_regression=evaluation.odometer_regression,
|
||||||
|
resulting_odometer_km=evaluation.resulting_odometer_km,
|
||||||
|
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
||||||
|
status_reason=evaluation.status_reason,
|
||||||
|
status_reason_code=evaluation.status_reason_code,
|
||||||
|
status_reason_params=evaluation.status_reason_params,
|
||||||
|
would_create_quality_issue=evaluation.would_create_quality_issue,
|
||||||
|
attention_reasons=evaluation.attention_reasons,
|
||||||
|
next_booking_risk=(
|
||||||
|
NextBookingRisk(**evaluation.next_booking_risk)
|
||||||
|
if evaluation.next_booking_risk is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{public_ref}/return")
|
@router.post("/{public_ref}/return")
|
||||||
def register_return(
|
def register_return(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|||||||
|
|
||||||
|
|
||||||
def _today() -> date:
|
def _today() -> date:
|
||||||
return datetime.fromisoformat(settings.demo_today).date()
|
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
|
||||||
|
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
|
||||||
|
return datetime.now(UTC).date()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DashboardOut)
|
@router.get("", response_model=DashboardOut)
|
||||||
@@ -59,12 +61,11 @@ def get_dashboard(
|
|||||||
entity = customers_by_id.get(issue.entity_id)
|
entity = customers_by_id.get(issue.entity_id)
|
||||||
link_type = "customer"
|
link_type = "customer"
|
||||||
link_ref = entity.public_ref if entity else ""
|
link_ref = entity.public_ref if entity else ""
|
||||||
title = f"{issue.rule_type.replace('_', ' ').title()} — {link_ref}"
|
|
||||||
attention_items.append(
|
attention_items.append(
|
||||||
AttentionItem(
|
AttentionItem(
|
||||||
kind="quality_issue",
|
kind="quality_issue",
|
||||||
severity=issue.severity,
|
severity=issue.severity,
|
||||||
title=title,
|
rule_type=issue.rule_type,
|
||||||
detail=issue.evidence_json.get("summary", ""),
|
detail=issue.evidence_json.get("summary", ""),
|
||||||
link_type=link_type,
|
link_type=link_type,
|
||||||
link_ref=link_ref,
|
link_ref=link_ref,
|
||||||
@@ -107,6 +108,7 @@ def get_dashboard(
|
|||||||
status=r.delivery_status,
|
status=r.delivery_status,
|
||||||
attempts=r.attempts,
|
attempts=r.attempts,
|
||||||
last_error=r.last_error,
|
last_error=r.last_error,
|
||||||
|
last_error_code=r.last_error_code,
|
||||||
occurred_at=r.occurred_at,
|
occurred_at=r.occurred_at,
|
||||||
)
|
)
|
||||||
for r in recent
|
for r in recent
|
||||||
|
|||||||
@@ -4,19 +4,38 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.models.booking import Booking
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.inspection import Inspection
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
|
ApplyRecommendedStatusRequest,
|
||||||
|
ApplyRecommendedStatusResult,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DataQualityIssueDetailOut,
|
DataQualityIssueDetailOut,
|
||||||
DataQualityIssueOut,
|
DataQualityIssueOut,
|
||||||
MergeCustomersRequest,
|
MergeCustomersRequest,
|
||||||
MergeCustomersResult,
|
MergeCustomersResult,
|
||||||
|
ProvideFieldsRequest,
|
||||||
|
ResolveOdometerRegressionRequest,
|
||||||
|
ResolveOverlapRequest,
|
||||||
ScanResultOut,
|
ScanResultOut,
|
||||||
|
StatusRecommendationOut,
|
||||||
|
VehicleStatusFactsOut,
|
||||||
|
)
|
||||||
|
from app.services.data_quality import (
|
||||||
|
apply_recommended_status,
|
||||||
|
defer_issue,
|
||||||
|
merge_customers,
|
||||||
|
preview_vehicle_status_recommendation,
|
||||||
|
provide_missing_fields,
|
||||||
|
reject_issue,
|
||||||
|
resolve_booking_overlap,
|
||||||
|
resolve_odometer_regression,
|
||||||
|
run_scan,
|
||||||
)
|
)
|
||||||
from app.services.data_quality import defer_issue, merge_customers, reject_issue, run_scan
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
||||||
|
|
||||||
@@ -41,7 +60,7 @@ def list_issues(
|
|||||||
rule_type: str | None = Query(default=None),
|
rule_type: str | None = Query(default=None),
|
||||||
severity: str | None = Query(default=None),
|
severity: str | None = Query(default=None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> list[DataQualityIssueOut]:
|
) -> list[DataQualityIssueOut]:
|
||||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||||
if status:
|
if status:
|
||||||
@@ -54,12 +73,33 @@ def list_issues(
|
|||||||
return [_to_out(i) for i in issues]
|
return [_to_out(i) for i in issues]
|
||||||
|
|
||||||
|
|
||||||
|
# Every public reference in this system carries its entity type in its own prefix
|
||||||
|
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
|
||||||
|
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
|
||||||
|
# bookings, not vehicles, and an inline odometer_regression issue's related refs mix a
|
||||||
|
# booking and an inspection ref in the same list.
|
||||||
|
_PREFIX_TO_TYPE = {
|
||||||
|
"CUS-": "customer",
|
||||||
|
"MO-": "vehicle",
|
||||||
|
"BK-": "booking",
|
||||||
|
"INSP-": "inspection",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_type_for_ref(ref: str) -> str | None:
|
||||||
|
for prefix, entity_type in _PREFIX_TO_TYPE.items():
|
||||||
|
if ref.startswith(prefix):
|
||||||
|
return entity_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||||
if entity_type == "customer":
|
if entity_type == "customer":
|
||||||
customer = db.scalar(select(Customer).where(Customer.public_ref == ref))
|
customer = db.scalar(select(Customer).where(Customer.public_ref == ref))
|
||||||
if customer is None:
|
if customer is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
|
"entity_type": "customer",
|
||||||
"public_ref": customer.public_ref,
|
"public_ref": customer.public_ref,
|
||||||
"first_name": customer.first_name,
|
"first_name": customer.first_name,
|
||||||
"last_name": customer.last_name,
|
"last_name": customer.last_name,
|
||||||
@@ -68,41 +108,74 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
"postal_code": customer.postal_code,
|
"postal_code": customer.postal_code,
|
||||||
"city": customer.city,
|
"city": customer.city,
|
||||||
}
|
}
|
||||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
|
if entity_type == "vehicle":
|
||||||
if vehicle is None:
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
|
||||||
return None
|
if vehicle is None:
|
||||||
return {
|
return None
|
||||||
"public_ref": vehicle.public_ref,
|
return {
|
||||||
"make": vehicle.make,
|
"entity_type": "vehicle",
|
||||||
"model": vehicle.model,
|
"public_ref": vehicle.public_ref,
|
||||||
"location": vehicle.location,
|
"make": vehicle.make,
|
||||||
"operational_status": vehicle.operational_status,
|
"model": vehicle.model,
|
||||||
"odometer_km": vehicle.odometer_km,
|
"location": vehicle.location,
|
||||||
}
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
}
|
||||||
|
if entity_type == "booking":
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == ref))
|
||||||
|
if booking is None:
|
||||||
|
return None
|
||||||
|
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||||
|
customer = db.get(Customer, booking.customer_id)
|
||||||
|
return {
|
||||||
|
"entity_type": "booking",
|
||||||
|
"public_ref": booking.public_ref,
|
||||||
|
"status": booking.status,
|
||||||
|
"starts_at": booking.starts_at.isoformat(),
|
||||||
|
"ends_at": booking.ends_at.isoformat(),
|
||||||
|
"vehicle_ref": vehicle.public_ref if vehicle else None,
|
||||||
|
"customer_ref": customer.public_ref if customer else None,
|
||||||
|
"end_odometer_km": booking.end_odometer_km,
|
||||||
|
}
|
||||||
|
if entity_type == "inspection":
|
||||||
|
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref))
|
||||||
|
if inspection is None:
|
||||||
|
return None
|
||||||
|
booking = db.get(Booking, inspection.booking_id)
|
||||||
|
return {
|
||||||
|
"entity_type": "inspection",
|
||||||
|
"public_ref": inspection.public_ref,
|
||||||
|
"type": inspection.type,
|
||||||
|
"odometer_km": inspection.odometer_km,
|
||||||
|
"completed_at": inspection.completed_at.isoformat(),
|
||||||
|
"booking_ref": booking.public_ref if booking else None,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
|
@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
|
||||||
def get_issue(
|
def get_issue(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(get_current_user),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> DataQualityIssueDetailOut:
|
) -> DataQualityIssueDetailOut:
|
||||||
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||||
if issue is None:
|
if issue is None:
|
||||||
raise HTTPException(status_code=404, detail="Data quality issue not found")
|
raise HTTPException(status_code=404, detail="Data quality issue not found")
|
||||||
base = _to_out(issue)
|
base = _to_out(issue)
|
||||||
related_refs = issue.evidence_json.get("related_refs", [])
|
related_refs = issue.evidence_json.get("related_refs", [])
|
||||||
related_entity_type = (
|
related_snapshots = []
|
||||||
"customer" if issue.rule_type == "possible_duplicate_customer" else "vehicle"
|
for ref in related_refs:
|
||||||
)
|
entity_type = _entity_type_for_ref(ref)
|
||||||
|
if entity_type is None:
|
||||||
|
continue
|
||||||
|
snap = _snapshot(entity_type, ref, db)
|
||||||
|
if snap is not None:
|
||||||
|
related_snapshots.append(snap)
|
||||||
return DataQualityIssueDetailOut(
|
return DataQualityIssueDetailOut(
|
||||||
**base.model_dump(),
|
**base.model_dump(),
|
||||||
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
|
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
|
||||||
related_snapshots=[
|
related_snapshots=related_snapshots,
|
||||||
snap
|
|
||||||
for ref in related_refs
|
|
||||||
if (snap := _snapshot(related_entity_type, ref, db)) is not None
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +183,7 @@ def get_issue(
|
|||||||
def defer(
|
def defer(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> DataQualityIssueOut:
|
) -> DataQualityIssueOut:
|
||||||
issue = defer_issue(db, public_ref, user)
|
issue = defer_issue(db, public_ref, user)
|
||||||
return _to_out(issue)
|
return _to_out(issue)
|
||||||
@@ -120,7 +193,7 @@ def defer(
|
|||||||
def reject(
|
def reject(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> DataQualityIssueOut:
|
) -> DataQualityIssueOut:
|
||||||
issue = reject_issue(db, public_ref, user)
|
issue = reject_issue(db, public_ref, user)
|
||||||
return _to_out(issue)
|
return _to_out(issue)
|
||||||
@@ -137,10 +210,85 @@ def merge(
|
|||||||
return MergeCustomersResult(**result)
|
return MergeCustomersResult(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/provide-fields", response_model=DataQualityIssueOut)
|
||||||
|
def provide_fields(
|
||||||
|
public_ref: str,
|
||||||
|
body: ProvideFieldsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = provide_missing_fields(db, public_ref, body.fields, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
|
||||||
|
)
|
||||||
|
def resolve_odometer(
|
||||||
|
public_ref: str,
|
||||||
|
body: ResolveOdometerRegressionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = resolve_odometer_regression(db, public_ref, body, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/resolve-overlap", response_model=DataQualityIssueOut)
|
||||||
|
def resolve_overlap(
|
||||||
|
public_ref: str,
|
||||||
|
body: ResolveOverlapRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = resolve_booking_overlap(db, public_ref, body.booking_ref, body.note, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
|
||||||
|
)
|
||||||
|
def status_recommendation(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> StatusRecommendationOut:
|
||||||
|
"""Non-mutating preview: computes the recommendation without changing anything,
|
||||||
|
resolving no issue and writing no audit event. Safe to call repeatedly."""
|
||||||
|
_issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref)
|
||||||
|
return StatusRecommendationOut(
|
||||||
|
current_status=recommendation.current_status,
|
||||||
|
recommended_status=recommendation.recommended_status,
|
||||||
|
recommendation_code=recommendation.recommendation_code,
|
||||||
|
safe_to_apply=recommendation.safe_to_apply,
|
||||||
|
manual_review_required=recommendation.manual_review_required,
|
||||||
|
facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()),
|
||||||
|
blocking_reasons=recommendation.blocking_reasons,
|
||||||
|
recommendation_token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
||||||
|
)
|
||||||
|
def apply_status(
|
||||||
|
public_ref: str,
|
||||||
|
body: ApplyRecommendedStatusRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> ApplyRecommendedStatusResult:
|
||||||
|
issue, applied_status, reason_code = apply_recommended_status(
|
||||||
|
db, public_ref, user, body.recommendation_token
|
||||||
|
)
|
||||||
|
return ApplyRecommendedStatusResult(
|
||||||
|
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scan", response_model=ScanResultOut)
|
@router.post("/scan", response_model=ScanResultOut)
|
||||||
def scan(
|
def scan(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> ScanResultOut:
|
) -> ScanResultOut:
|
||||||
result = run_scan(db)
|
result = run_scan(db, actor_label=user.display_name, actor_type="user")
|
||||||
return ScanResultOut(created=result.created)
|
return ScanResultOut(created=result.created)
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import SessionPayload, create_session_token
|
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas import CurrentUser, DemoLoginRequest
|
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||||
from app.seed_loader import reset_and_seed
|
from app.seed_loader import reset_and_seed
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest", response_model=DemoManifestOut)
|
||||||
|
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||||
|
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
|
||||||
|
# both need this before any session exists. Nothing here is sensitive — it's the same
|
||||||
|
# honest "what is this demo" summary a logged-in user would see.
|
||||||
|
return build_demo_manifest(db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=CurrentUser)
|
@router.post("/login", response_model=CurrentUser)
|
||||||
def demo_login(
|
def demo_login(
|
||||||
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||||
@@ -41,6 +51,7 @@ def demo_login(
|
|||||||
token,
|
token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
secure=settings.session_cookie_secure,
|
||||||
max_age=settings.session_ttl_seconds,
|
max_age=settings.session_ttl_seconds,
|
||||||
)
|
)
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
@@ -56,21 +67,65 @@ def demo_login(
|
|||||||
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=CurrentUser)
|
||||||
|
def get_session(
|
||||||
|
response: Response, user: CurrentUser = Depends(get_current_user)
|
||||||
|
) -> CurrentUser:
|
||||||
|
# Never let the browser (or an intermediary) cache an authentication check — a stale
|
||||||
|
# cached 200 here would keep showing a logged-out browser as authenticated.
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
payload = read_session_token(token) if token else None
|
||||||
|
if payload is not None:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=uuid.UUID(payload.user_id),
|
||||||
|
actor_label=payload.display_name,
|
||||||
|
action="demo_logout",
|
||||||
|
entity_type="user",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
|
return {"status": "logged_out"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset")
|
@router.post("/reset")
|
||||||
def demo_reset(
|
def demo_reset(
|
||||||
response: Response,
|
response: Response,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(require_operations_manager),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
if not settings.demo_allow_reset:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Demo reset is disabled on this deployment.",
|
||||||
|
)
|
||||||
result = reset_and_seed(db)
|
result = reset_and_seed(db)
|
||||||
|
integrity = scenario_integrity_report(db)
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
actor_type="user",
|
actor_type="user",
|
||||||
actor_label=user.display_name,
|
actor_label=user.display_name,
|
||||||
action="demo_reset",
|
action="demo_reset",
|
||||||
entity_type="system",
|
entity_type="system",
|
||||||
metadata={"counts": result.counts},
|
metadata={
|
||||||
|
"counts": result.counts,
|
||||||
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
response.delete_cookie(settings.session_cookie_name)
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
return {"status": "reset", "counts": result.counts}
|
return {
|
||||||
|
"status": "reset",
|
||||||
|
"counts": result.counts,
|
||||||
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
|
"seeded_at": result.seeded_at.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.schemas import (
|
||||||
|
CurrentUser,
|
||||||
|
IntegrationStatusOut,
|
||||||
|
McpHubIntegrationStatus,
|
||||||
|
)
|
||||||
|
from app.services.integration_status import derive_n8n_status
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=IntegrationStatusOut)
|
||||||
|
def integration_status(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> IntegrationStatusOut:
|
||||||
|
return IntegrationStatusOut(
|
||||||
|
n8n=derive_n8n_status(db),
|
||||||
|
mcp_hub=McpHubIntegrationStatus(
|
||||||
|
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||||
|
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
@@ -13,7 +14,18 @@ from app.core.config import get_settings
|
|||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.audit import AuditEvent
|
from app.models.audit import AuditEvent
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.schemas import (
|
||||||
|
ProcedureDocumentOut,
|
||||||
|
ProcedureListOut,
|
||||||
|
ProcedureSyncResultIn,
|
||||||
|
ProcedureSyncResultResult,
|
||||||
|
ScanResultOut,
|
||||||
|
WorkflowErrorReportIn,
|
||||||
|
WorkflowErrorReportResult,
|
||||||
|
)
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import run_scan
|
||||||
|
from app.services.knowledge.procedures import iter_procedure_documents
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -71,3 +83,140 @@ def return_callback(
|
|||||||
"event_id": str(event_id),
|
"event_id": str(event_id),
|
||||||
"occurred_at": datetime.now(UTC).isoformat(),
|
"occurred_at": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scheduled-scan", response_model=ScanResultOut)
|
||||||
|
def scheduled_scan(
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ScanResultOut:
|
||||||
|
"""Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and
|
||||||
|
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
||||||
|
doesn't already have one open, so a duplicate or overlapping trigger does no
|
||||||
|
duplicate domain work -- it just reports zero new issues for anything already known."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||||
|
return ScanResultOut(created=result.created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
||||||
|
def workflow_error(
|
||||||
|
body: WorkflowErrorReportIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> WorkflowErrorReportResult:
|
||||||
|
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
||||||
|
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||||
|
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||||
|
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
correlation_id: uuid.UUID | None = None
|
||||||
|
if body.correlation_id:
|
||||||
|
try:
|
||||||
|
correlation_id = uuid.UUID(body.correlation_id)
|
||||||
|
except ValueError:
|
||||||
|
correlation_id = None
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n error handler",
|
||||||
|
action="n8n_workflow_failure_registered",
|
||||||
|
entity_type="automation",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"error_category": body.error_category,
|
||||||
|
"error_summary": body.error_summary,
|
||||||
|
"trigger_context": body.trigger_context,
|
||||||
|
"attempt": body.attempt,
|
||||||
|
"retry_action": body.retry_action,
|
||||||
|
"failed_at": body.failed_at.isoformat(),
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return WorkflowErrorReportResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/procedures", response_model=ProcedureListOut)
|
||||||
|
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
|
||||||
|
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
|
||||||
|
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||||
|
per-document id (source_id) and a content hash so the caller can detect changes
|
||||||
|
without re-fetching content it already has."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
documents = [
|
||||||
|
ProcedureDocumentOut(
|
||||||
|
id=doc.source_id,
|
||||||
|
language=doc.language,
|
||||||
|
document_id=doc.document_id,
|
||||||
|
title=doc.title,
|
||||||
|
version=doc.version,
|
||||||
|
content=doc.content,
|
||||||
|
content_hash=doc.content_hash,
|
||||||
|
)
|
||||||
|
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
|
||||||
|
]
|
||||||
|
return ProcedureListOut(documents=documents)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
|
||||||
|
def procedures_sync_result(
|
||||||
|
body: ProcedureSyncResultIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProcedureSyncResultResult:
|
||||||
|
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
||||||
|
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||||
|
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_procedures_synced",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n procedure sync",
|
||||||
|
action="n8n_procedures_synced",
|
||||||
|
entity_type="automation",
|
||||||
|
after={"synced": body.synced, "failed": body.failed},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ProcedureSyncResultResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -13,9 +14,12 @@ from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledg
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||||
|
|
||||||
|
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
|
||||||
|
|
||||||
|
|
||||||
class AskQuestionRequest(BaseModel):
|
class AskQuestionRequest(BaseModel):
|
||||||
question: str = Field(min_length=3, max_length=1000)
|
question: str = Field(min_length=3, max_length=1000)
|
||||||
|
language: SupportedLanguage = "en-GB"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/questions", response_model=GroundedAnswer)
|
@router.post("/questions", response_model=GroundedAnswer)
|
||||||
@@ -26,7 +30,7 @@ def ask_question(
|
|||||||
) -> GroundedAnswer:
|
) -> GroundedAnswer:
|
||||||
correlation_id = str(uuid.uuid4())
|
correlation_id = str(uuid.uuid4())
|
||||||
provider = get_knowledge_provider()
|
provider = get_knowledge_provider()
|
||||||
answer = provider.ask(body.question, correlation_id)
|
answer = provider.ask(body.question, correlation_id, body.language)
|
||||||
|
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
@@ -40,6 +44,7 @@ def ask_question(
|
|||||||
"provider": answer.provider,
|
"provider": answer.provider,
|
||||||
"source_ids": [s.document_id for s in answer.sources],
|
"source_ids": [s.document_id for s in answer.sources],
|
||||||
"question_length": len(body.question),
|
"question_length": len(body.question),
|
||||||
|
"language": body.language,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -47,5 +52,8 @@ def ask_question(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=KnowledgeHealth)
|
@router.get("/status", response_model=KnowledgeHealth)
|
||||||
def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth:
|
def knowledge_status(
|
||||||
return get_knowledge_provider().health()
|
language: SupportedLanguage = "en-GB",
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> KnowledgeHealth:
|
||||||
|
return get_knowledge_provider().health(language)
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||||
|
|
||||||
|
# Static application sections. `id` is a stable code matching navigation.json's
|
||||||
|
# `items.*` keys -- the frontend localizes both the section label and its one-line
|
||||||
|
# detail from `id`, so no English prose is sent over the wire (search.sections.<id> in
|
||||||
|
# every locale; see docs/fleet-ops-correction/i18n-inventory.md). Manager-only sections
|
||||||
|
# are filtered by role, mirroring the same nav visibility rule Layout.tsx applies --
|
||||||
|
# search must never surface a destination the current role can't actually reach.
|
||||||
|
_SECTIONS: list[dict] = [
|
||||||
|
{
|
||||||
|
"id": "overview",
|
||||||
|
"link": "/dashboard",
|
||||||
|
# Search terms deliberately span all three supported UI languages (not just
|
||||||
|
# English) so a query never depends on the operator's selected locale.
|
||||||
|
"terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fleet",
|
||||||
|
"link": "/vehicles",
|
||||||
|
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bookings",
|
||||||
|
"link": "/bookings",
|
||||||
|
"terms": [
|
||||||
|
"booking",
|
||||||
|
"bookings",
|
||||||
|
"rental",
|
||||||
|
"boeking",
|
||||||
|
"boekingen",
|
||||||
|
"verhuur",
|
||||||
|
"réservation",
|
||||||
|
"réservations",
|
||||||
|
"location",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quality",
|
||||||
|
"link": "/data-quality",
|
||||||
|
"terms": [
|
||||||
|
"quality",
|
||||||
|
"data quality",
|
||||||
|
"issues",
|
||||||
|
"kwaliteit",
|
||||||
|
"datakwaliteit",
|
||||||
|
"problemen",
|
||||||
|
"qualité",
|
||||||
|
"problèmes",
|
||||||
|
],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "knowledge",
|
||||||
|
"link": "/knowledge",
|
||||||
|
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "integrations",
|
||||||
|
"link": "/automation",
|
||||||
|
"terms": [
|
||||||
|
"automation",
|
||||||
|
"integrations",
|
||||||
|
"systems",
|
||||||
|
"n8n",
|
||||||
|
"automatisering",
|
||||||
|
"integraties",
|
||||||
|
"systemen",
|
||||||
|
"automatisation",
|
||||||
|
"intégrations",
|
||||||
|
"systèmes",
|
||||||
|
],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit",
|
||||||
|
"link": "/audit",
|
||||||
|
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SearchResponse)
|
||||||
|
def search(
|
||||||
|
q: str = Query(min_length=1, max_length=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> SearchResponse:
|
||||||
|
query = q.strip()
|
||||||
|
normalized = query.lower()
|
||||||
|
results: list[SearchResultItem] = []
|
||||||
|
|
||||||
|
for section in _SECTIONS:
|
||||||
|
role = section.get("role")
|
||||||
|
if role and user.role != role:
|
||||||
|
continue
|
||||||
|
terms: list[str] = section["terms"]
|
||||||
|
if any(term in normalized or normalized in term for term in terms):
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="section",
|
||||||
|
label=section["id"],
|
||||||
|
detail_code=section["id"],
|
||||||
|
link=section["link"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
like = f"%{query}%"
|
||||||
|
for v in db.scalars(
|
||||||
|
select(Vehicle)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(like),
|
||||||
|
Vehicle.make.ilike(like),
|
||||||
|
Vehicle.model.ilike(like),
|
||||||
|
Vehicle.registration_number.ilike(like),
|
||||||
|
Vehicle.location.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Vehicle.public_ref)
|
||||||
|
.limit(5)
|
||||||
|
).all():
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="vehicle",
|
||||||
|
label=v.public_ref,
|
||||||
|
detail_code="vehicleSummary",
|
||||||
|
detail_params={"make": v.make, "model": v.model, "location": v.location},
|
||||||
|
link=f"/vehicles/{v.public_ref}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for b in db.scalars(
|
||||||
|
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
||||||
|
).all():
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="booking",
|
||||||
|
label=b.public_ref,
|
||||||
|
detail_code=b.status,
|
||||||
|
link=f"/bookings/{b.public_ref}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# No customer detail route exists in this proof of concept, so customers are
|
||||||
|
# deliberately never returned here -- there is nowhere useful to send the user.
|
||||||
|
if user.role == "operations_manager":
|
||||||
|
for i in db.scalars(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(DataQualityIssue.public_ref.ilike(like))
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
).all():
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="data_quality_issue",
|
||||||
|
label=i.public_ref,
|
||||||
|
detail_code=i.rule_type,
|
||||||
|
link=f"/data-quality/{i.public_ref}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(query=query, results=results[:10])
|
||||||
@@ -23,6 +23,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
|||||||
status=event.delivery_status,
|
status=event.delivery_status,
|
||||||
attempts=event.attempts,
|
attempts=event.attempts,
|
||||||
last_error=event.last_error,
|
last_error=event.last_error,
|
||||||
|
last_error_code=event.last_error_code,
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ from functools import lru_cache
|
|||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# The visible product name is fixed and never translated or configured per-deployment --
|
||||||
|
# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers
|
||||||
|
# (package name, Compose project, database name, repository) intentionally remain
|
||||||
|
# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title).
|
||||||
|
PRODUCT_NAME = "Fleet Ops"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
@@ -15,21 +21,28 @@ class Settings(BaseSettings):
|
|||||||
ragcore_workspace: str = "mobilityops"
|
ragcore_workspace: str = "mobilityops"
|
||||||
ragcore_collection: str = "internal-procedures"
|
ragcore_collection: str = "internal-procedures"
|
||||||
ragcore_api_token: str = ""
|
ragcore_api_token: str = ""
|
||||||
|
ragcore_space_id: str = ""
|
||||||
ragcore_http_timeout_seconds: float = 5.0
|
ragcore_http_timeout_seconds: float = 5.0
|
||||||
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
||||||
|
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
|
||||||
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||||
n8n_dispatch_enabled: bool = True
|
n8n_dispatch_enabled: bool = True
|
||||||
n8n_dispatch_interval_seconds: float = 3.0
|
n8n_dispatch_interval_seconds: float = 3.0
|
||||||
n8n_http_timeout_seconds: float = 5.0
|
n8n_http_timeout_seconds: float = 5.0
|
||||||
n8n_max_attempts: int = 5
|
n8n_max_attempts: int = 5
|
||||||
|
n8n_delivery_lease_seconds: float = 120.0
|
||||||
app_secret: str = "replace-in-production"
|
app_secret: str = "replace-in-production"
|
||||||
session_cookie_name: str = "mobilityops_session"
|
session_cookie_name: str = "mobilityops_session"
|
||||||
session_ttl_seconds: int = 60 * 60 * 8
|
session_ttl_seconds: int = 60 * 60 * 8
|
||||||
|
session_cookie_secure: bool = False
|
||||||
seed_dir: str = "/app/seed"
|
seed_dir: str = "/app/seed"
|
||||||
knowledge_dir: str = "/app/knowledge/procedures"
|
knowledge_dir: str = "/app/knowledge/procedures"
|
||||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||||
|
mcp_hub_registration_enabled: bool = False
|
||||||
cors_allow_origins: str = "http://localhost:1228"
|
cors_allow_origins: str = "http://localhost:1228"
|
||||||
demo_today: str = "2026-08-01"
|
demo_organization_name: str = "Northstar Mobility"
|
||||||
|
demo_timezone: str = "Europe/Brussels"
|
||||||
|
demo_allow_reset: bool = True
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ from app.api.routers import (
|
|||||||
dashboard,
|
dashboard,
|
||||||
data_quality,
|
data_quality,
|
||||||
demo,
|
demo,
|
||||||
|
integration_status,
|
||||||
integrations,
|
integrations,
|
||||||
knowledge,
|
knowledge,
|
||||||
mcp_integrations,
|
mcp_integrations,
|
||||||
|
search,
|
||||||
vehicles,
|
vehicles,
|
||||||
workflows,
|
workflows,
|
||||||
)
|
)
|
||||||
from app.core.config import get_settings
|
from app.core.config import PRODUCT_NAME, get_settings
|
||||||
from app.core.errors import AppError, error_body
|
from app.core.errors import AppError, error_body
|
||||||
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
stop_background_dispatcher()
|
stop_background_dispatcher()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MobilityOps API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -88,3 +90,5 @@ app.include_router(workflows.router)
|
|||||||
app.include_router(integrations.router)
|
app.include_router(integrations.router)
|
||||||
app.include_router(knowledge.router)
|
app.include_router(knowledge.router)
|
||||||
app.include_router(mcp_integrations.router)
|
app.include_router(mcp_integrations.router)
|
||||||
|
app.include_router(search.router)
|
||||||
|
app.include_router(integration_status.router)
|
||||||
|
|||||||
@@ -26,4 +26,9 @@ class OutboxEvent(TimestampMixin, Base):
|
|||||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_error: Mapped[str | None] = mapped_column(Text)
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Stable, localizable classification of last_error -- the frontend renders a
|
||||||
|
# localized summary from this code as the primary text and shows last_error itself
|
||||||
|
# only under "Technical details" (section 10 of docs/fleet-ops-correction/
|
||||||
|
# current-gap-audit.md). Kept alongside the raw message for backward compatibility.
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(60))
|
||||||
external_run_id: Mapped[str | None] = mapped_column(String(120))
|
external_run_id: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ class RegisterReturnResult(BaseModel):
|
|||||||
next_booking_risk: NextBookingRisk | None
|
next_booking_risk: NextBookingRisk | None
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnPreviewResult(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
vehicle_ref: str
|
||||||
|
canonical_odometer_km: int
|
||||||
|
submitted_odometer_km: int
|
||||||
|
odometer_regression: bool
|
||||||
|
resulting_odometer_km: int
|
||||||
|
resulting_vehicle_status: str
|
||||||
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int] = {}
|
||||||
|
would_create_quality_issue: bool
|
||||||
|
attention_reasons: list[str]
|
||||||
|
next_booking_risk: NextBookingRisk | None
|
||||||
|
|
||||||
|
|
||||||
class InspectionOut(BaseModel):
|
class InspectionOut(BaseModel):
|
||||||
public_ref: str
|
public_ref: str
|
||||||
booking_ref: str
|
booking_ref: str
|
||||||
@@ -127,6 +143,181 @@ class ScanResultOut(BaseModel):
|
|||||||
created: dict[str, int]
|
created: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportIn(BaseModel):
|
||||||
|
workflow_id: str = Field(max_length=120)
|
||||||
|
workflow_name: str = Field(max_length=200)
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
failed_at: datetime
|
||||||
|
error_category: Literal[
|
||||||
|
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
|
||||||
|
]
|
||||||
|
error_summary: str = Field(max_length=500)
|
||||||
|
trigger_context: str | None = Field(default=None, max_length=200)
|
||||||
|
correlation_id: str | None = None
|
||||||
|
attempt: int = Field(default=1, ge=1, le=1000)
|
||||||
|
retry_action: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureDocumentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureListOut(BaseModel):
|
||||||
|
documents: list[ProcedureDocumentOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultIn(BaseModel):
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
synced: int = Field(ge=0)
|
||||||
|
failed: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProvideFieldsRequest(BaseModel):
|
||||||
|
fields: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveOdometerRegressionRequest(BaseModel):
|
||||||
|
decision: Literal["retain_canonical", "correct_reading"]
|
||||||
|
booking_ref: str | None = None
|
||||||
|
corrected_odometer_km: Annotated[int, Field(ge=0)] | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveOverlapRequest(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleStatusFactsOut(BaseModel):
|
||||||
|
active_booking_refs: list[str]
|
||||||
|
overlapping_booking_pairs: list[list[str]]
|
||||||
|
service_threshold_reached: bool
|
||||||
|
odometer_km: int
|
||||||
|
next_service_km: int
|
||||||
|
open_booking_overlap_issue_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusRecommendationOut(BaseModel):
|
||||||
|
current_status: str
|
||||||
|
recommended_status: str | None
|
||||||
|
recommendation_code: str
|
||||||
|
safe_to_apply: bool
|
||||||
|
manual_review_required: bool
|
||||||
|
facts: VehicleStatusFactsOut
|
||||||
|
blocking_reasons: list[str]
|
||||||
|
recommendation_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyRecommendedStatusRequest(BaseModel):
|
||||||
|
recommendation_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyRecommendedStatusResult(BaseModel):
|
||||||
|
issue: DataQualityIssueOut
|
||||||
|
applied_status: str
|
||||||
|
reason_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultItem(BaseModel):
|
||||||
|
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||||
|
label: str
|
||||||
|
detail_code: str
|
||||||
|
detail_params: dict[str, str] = {}
|
||||||
|
link: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
query: str
|
||||||
|
results: list[SearchResultItem]
|
||||||
|
|
||||||
|
|
||||||
|
class N8nWorkflowEvidence(BaseModel):
|
||||||
|
name: str
|
||||||
|
built: bool
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class N8nErrorHandlerStatus(BaseModel):
|
||||||
|
total_failures_registered: int
|
||||||
|
latest_failure_at: datetime | None
|
||||||
|
latest_failure_workflow: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class N8nIntegrationStatus(BaseModel):
|
||||||
|
configured: bool
|
||||||
|
dispatch_enabled: bool
|
||||||
|
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||||
|
pending: int
|
||||||
|
delivering: int
|
||||||
|
failed: int
|
||||||
|
succeeded: int
|
||||||
|
latest_success_at: datetime | None
|
||||||
|
latest_failure_at: datetime | None
|
||||||
|
expected_workflow_count: int
|
||||||
|
known_workflow_count: int
|
||||||
|
workflows: list[N8nWorkflowEvidence]
|
||||||
|
error_handler: N8nErrorHandlerStatus
|
||||||
|
|
||||||
|
|
||||||
|
class McpHubIntegrationStatus(BaseModel):
|
||||||
|
registration_enabled: bool
|
||||||
|
state: Literal["not_configured", "configured"]
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationStatusOut(BaseModel):
|
||||||
|
n8n: N8nIntegrationStatus
|
||||||
|
mcp_hub: McpHubIntegrationStatus
|
||||||
|
|
||||||
|
|
||||||
|
class DemoScenarioOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
estimated_minutes: int
|
||||||
|
required_roles: list[Role]
|
||||||
|
start_path: str
|
||||||
|
ready: bool
|
||||||
|
blocked_reason_code: str | None = None
|
||||||
|
blocked_reason_params: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class DemoIntegrationSummaryOut(BaseModel):
|
||||||
|
key: Literal["n8n", "ragcore", "mcp_hub"]
|
||||||
|
status_code: str
|
||||||
|
detail_code: str
|
||||||
|
detail_params: dict[str, str | int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class DemoManifestOut(BaseModel):
|
||||||
|
demo_mode: bool
|
||||||
|
organization_name: str
|
||||||
|
timezone: str
|
||||||
|
synthetic_data: bool
|
||||||
|
allow_reset: bool
|
||||||
|
last_reset_at: datetime | None
|
||||||
|
anchor_date: str | None
|
||||||
|
guide_available: bool
|
||||||
|
required_roles: list[Role]
|
||||||
|
scenarios: list[DemoScenarioOut]
|
||||||
|
integrations: list[DemoIntegrationSummaryOut]
|
||||||
|
|
||||||
|
|
||||||
class VehicleDetailOut(VehicleOut):
|
class VehicleDetailOut(VehicleOut):
|
||||||
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||||
inspections: list[InspectionOut] = Field(default_factory=list)
|
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||||
@@ -147,7 +338,7 @@ class DashboardMetrics(BaseModel):
|
|||||||
class AttentionItem(BaseModel):
|
class AttentionItem(BaseModel):
|
||||||
kind: Literal["quality_issue", "vehicle"]
|
kind: Literal["quality_issue", "vehicle"]
|
||||||
severity: str
|
severity: str
|
||||||
title: str
|
rule_type: str
|
||||||
detail: str
|
detail: str
|
||||||
link_type: Literal["vehicle", "booking", "customer"]
|
link_type: Literal["vehicle", "booking", "customer"]
|
||||||
link_ref: str
|
link_ref: str
|
||||||
@@ -168,6 +359,7 @@ class AutomationRunOut(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
attempts: int
|
attempts: int
|
||||||
last_error: str | None
|
last_error: str | None
|
||||||
|
last_error_code: str | None
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +408,10 @@ class AuditEventOut(BaseModel):
|
|||||||
action: str
|
action: str
|
||||||
entity_type: str
|
entity_type: str
|
||||||
entity_id: str | None
|
entity_id: str | None
|
||||||
|
entity_ref: str | None = None
|
||||||
|
entity_link: str | None = None
|
||||||
correlation_id: str
|
correlation_id: str
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
|
before: dict[str, Any] | None = None
|
||||||
|
after: dict[str, Any] | None = None
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
from difflib import SequenceMatcher
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import delete, insert, update
|
from sqlalchemy import delete, insert, update
|
||||||
@@ -20,6 +21,7 @@ from app.models.maintenance import MaintenanceRecord
|
|||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -36,6 +38,17 @@ DEMO_USERS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# seed/generate_seed.py authored the committed CSVs relative to this fixed date
|
||||||
|
# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every
|
||||||
|
# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps
|
||||||
|
# right now" scenarios stay true to the actual reset moment instead of decaying as real
|
||||||
|
# time passes between resets -- a fixed anchor with no shift goes stale within days.
|
||||||
|
SEED_AUTHORED_ANCHOR = date(2026, 8, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_anchor_shift(today: date) -> timedelta:
|
||||||
|
return today - SEED_AUTHORED_ANCHOR
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(value: str) -> datetime:
|
def _parse_dt(value: str) -> datetime:
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
@@ -53,6 +66,8 @@ def _parse_optional_int(value: str) -> int | None:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class SeedResult:
|
class SeedResult:
|
||||||
counts: dict[str, int]
|
counts: dict[str, int]
|
||||||
|
anchor_date: date
|
||||||
|
seeded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
def _seed_dir() -> Path:
|
def _seed_dir() -> Path:
|
||||||
@@ -83,6 +98,8 @@ def clear_all(db: Session) -> None:
|
|||||||
|
|
||||||
def load_seed(db: Session) -> SeedResult:
|
def load_seed(db: Session) -> SeedResult:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
shift = _seed_anchor_shift(today)
|
||||||
|
|
||||||
user_rows = [
|
user_rows = [
|
||||||
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
|
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
|
||||||
@@ -92,21 +109,22 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
|
|
||||||
customer_id_by_ref: dict[str, uuid.UUID] = {}
|
customer_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
customer_rows = []
|
customer_rows = []
|
||||||
|
customer_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("customers.csv"):
|
for row in _read_csv("customers.csv"):
|
||||||
cid = uuid.uuid4()
|
cid = uuid.uuid4()
|
||||||
customer_id_by_ref[row["public_ref"]] = cid
|
customer_id_by_ref[row["public_ref"]] = cid
|
||||||
customer_rows.append(
|
customer_row = {
|
||||||
{
|
"id": cid,
|
||||||
"id": cid,
|
"public_ref": row["public_ref"],
|
||||||
"public_ref": row["public_ref"],
|
"first_name": row["first_name"],
|
||||||
"first_name": row["first_name"],
|
"last_name": row["last_name"],
|
||||||
"last_name": row["last_name"],
|
"email": row["email"] or None,
|
||||||
"email": row["email"] or None,
|
"phone": row["phone"] or None,
|
||||||
"phone": row["phone"] or None,
|
"postal_code": row["postal_code"] or None,
|
||||||
"postal_code": row["postal_code"] or None,
|
"city": row["city"] or None,
|
||||||
"city": row["city"] or None,
|
}
|
||||||
}
|
customer_rows.append(customer_row)
|
||||||
)
|
customer_row_by_ref[row["public_ref"]] = customer_row
|
||||||
db.execute(insert(Customer), customer_rows)
|
db.execute(insert(Customer), customer_rows)
|
||||||
counts["customers"] = len(customer_rows)
|
counts["customers"] = len(customer_rows)
|
||||||
# Second pass for merged_into (self-referencing FK) since target must exist first.
|
# Second pass for merged_into (self-referencing FK) since target must exist first.
|
||||||
@@ -154,8 +172,8 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"public_ref": row["public_ref"],
|
"public_ref": row["public_ref"],
|
||||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
"starts_at": _parse_dt(row["starts_at"]),
|
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||||
"ends_at": _parse_dt(row["ends_at"]),
|
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||||
"status": row["status"],
|
"status": row["status"],
|
||||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||||
@@ -179,7 +197,7 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"damage_reported": _parse_bool(row["damage_reported"]),
|
"damage_reported": _parse_bool(row["damage_reported"]),
|
||||||
"technical_warning": _parse_bool(row["technical_warning"]),
|
"technical_warning": _parse_bool(row["technical_warning"]),
|
||||||
"odometer_km": int(row["odometer_km"]),
|
"odometer_km": int(row["odometer_km"]),
|
||||||
"completed_at": _parse_dt(row["completed_at"]),
|
"completed_at": _parse_dt(row["completed_at"]) + shift,
|
||||||
"completed_by": None,
|
"completed_by": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -193,7 +211,7 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"id": uuid.uuid4(),
|
"id": uuid.uuid4(),
|
||||||
"public_ref": row["public_ref"],
|
"public_ref": row["public_ref"],
|
||||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
"occurred_at": _parse_dt(row["occurred_at"]),
|
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||||
"odometer_km": int(row["odometer_km"]),
|
"odometer_km": int(row["odometer_km"]),
|
||||||
"category": row["category"],
|
"category": row["category"],
|
||||||
"summary": row["summary"],
|
"summary": row["summary"],
|
||||||
@@ -207,11 +225,42 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
return "customer", customer_id_by_ref[entity_ref]
|
return "customer", customer_id_by_ref[entity_ref]
|
||||||
return "vehicle", vehicle_id_by_ref[entity_ref]
|
return "vehicle", vehicle_id_by_ref[entity_ref]
|
||||||
|
|
||||||
|
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
||||||
|
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
||||||
|
# they carry real, accurate structured signals (not just a legacy English
|
||||||
|
# sentence) -- the frontend renders these as the primary, localized evidence;
|
||||||
|
# see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||||
|
if public_ref == "DQ-DEMO-DUPLICATE":
|
||||||
|
a = customer_row_by_ref[entity_ref]
|
||||||
|
b = customer_row_by_ref[related_refs[0]]
|
||||||
|
name_a = f"{a['first_name']} {a['last_name']}".strip().lower()
|
||||||
|
name_b = f"{b['first_name']} {b['last_name']}".strip().lower()
|
||||||
|
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||||
|
return [
|
||||||
|
{"code": "duplicate.exact_email"},
|
||||||
|
{"code": "duplicate.exact_phone"},
|
||||||
|
{"code": "duplicate.same_postal_code"},
|
||||||
|
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}},
|
||||||
|
]
|
||||||
|
if public_ref == "DQ-DEMO-OVERLAP":
|
||||||
|
return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}]
|
||||||
|
if public_ref == "DQ-DEMO-STATUS":
|
||||||
|
return [{"code": "vehicle.booking_conflict"}]
|
||||||
|
if public_ref == "DQ-DEMO-ATTENTION":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": "attention.upcoming_booking_missing_inspection",
|
||||||
|
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
dq_rows = []
|
dq_rows = []
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
for row in _read_csv("data_quality_issues.csv"):
|
for row in _read_csv("data_quality_issues.csv"):
|
||||||
entity_type, entity_id = resolve_entity(row["entity_ref"])
|
entity_type, entity_id = resolve_entity(row["entity_ref"])
|
||||||
related_ref = row.get("related_ref") or ""
|
related_ref = row.get("related_ref") or ""
|
||||||
|
related_refs = related_ref.split("|") if related_ref else []
|
||||||
dq_rows.append(
|
dq_rows.append(
|
||||||
{
|
{
|
||||||
"id": uuid.uuid4(),
|
"id": uuid.uuid4(),
|
||||||
@@ -224,7 +273,8 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"evidence_json": {
|
"evidence_json": {
|
||||||
"summary": row["evidence"],
|
"summary": row["evidence"],
|
||||||
"entity_ref": row["entity_ref"],
|
"entity_ref": row["entity_ref"],
|
||||||
"related_refs": related_ref.split("|") if related_ref else [],
|
"related_refs": related_refs,
|
||||||
|
"signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs),
|
||||||
},
|
},
|
||||||
"proposed_action_json": {},
|
"proposed_action_json": {},
|
||||||
"detected_at": now,
|
"detected_at": now,
|
||||||
@@ -266,18 +316,35 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
},
|
},
|
||||||
"aggregate_ref": row["aggregate_ref"],
|
"aggregate_ref": row["aggregate_ref"],
|
||||||
},
|
},
|
||||||
"occurred_at": _parse_dt(row["occurred_at"]),
|
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||||
"delivery_status": row["status"],
|
"delivery_status": row["status"],
|
||||||
"attempts": int(row["attempts"]),
|
"attempts": int(row["attempts"]),
|
||||||
"next_attempt_at": None,
|
"next_attempt_at": None,
|
||||||
"last_error": row["last_error"] or None,
|
"last_error": row["last_error"] or None,
|
||||||
|
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
||||||
|
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
||||||
|
"last_error_code": "connectionError" if row["last_error"] else None,
|
||||||
"external_run_id": None,
|
"external_run_id": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
db.execute(insert(OutboxEvent), outbox_rows)
|
db.execute(insert(OutboxEvent), outbox_rows)
|
||||||
counts["workflow_runs"] = len(outbox_rows)
|
counts["workflow_runs"] = len(outbox_rows)
|
||||||
|
|
||||||
return SeedResult(counts=counts)
|
seeded_at = datetime.now(UTC)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="system",
|
||||||
|
actor_label="seed loader",
|
||||||
|
action="demo_data_seeded",
|
||||||
|
entity_type="system",
|
||||||
|
metadata={
|
||||||
|
"anchor_date": today.isoformat(),
|
||||||
|
"seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(),
|
||||||
|
"counts": counts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||||
|
|
||||||
|
|
||||||
def reset_and_seed(db: Session) -> SeedResult:
|
def reset_and_seed(db: Session) -> SeedResult:
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ from app.models.booking import Booking
|
|||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import CurrentUser
|
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.vehicle_status import (
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
VehicleStatusRecommendation,
|
||||||
|
compute_recommendation_token,
|
||||||
|
evaluate_vehicle_status,
|
||||||
|
gather_vehicle_status_facts,
|
||||||
|
)
|
||||||
|
|
||||||
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||||
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||||
@@ -69,10 +76,38 @@ def _open_issue(
|
|||||||
summary: str,
|
summary: str,
|
||||||
entity_ref: str,
|
entity_ref: str,
|
||||||
related_refs: list[str],
|
related_refs: list[str],
|
||||||
|
signals: list[dict] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
||||||
return
|
return
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Reintroduced evidence creates a new issue rather than silently reopening the old
|
||||||
|
# one, but it stays linked to whatever decision was made last time so an operator
|
||||||
|
# doesn't re-litigate from a blank slate.
|
||||||
|
previous = db.scalar(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(
|
||||||
|
DataQualityIssue.rule_type == rule_type,
|
||||||
|
DataQualityIssue.entity_type == entity_type,
|
||||||
|
DataQualityIssue.entity_id == entity_id,
|
||||||
|
DataQualityIssue.status != "open",
|
||||||
|
)
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
)
|
||||||
|
# `summary` is kept as a technical-fallback string (shown only under "Technical
|
||||||
|
# details"); `signals` is the stable, localizable structure the frontend renders as
|
||||||
|
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
|
||||||
|
evidence: dict = {
|
||||||
|
"summary": summary,
|
||||||
|
"entity_ref": entity_ref,
|
||||||
|
"related_refs": related_refs,
|
||||||
|
"signals": signals or [],
|
||||||
|
}
|
||||||
|
if previous is not None:
|
||||||
|
evidence["reopened_from"] = previous.public_ref
|
||||||
|
evidence["previous_decision"] = previous.status
|
||||||
|
|
||||||
issue = DataQualityIssue(
|
issue = DataQualityIssue(
|
||||||
public_ref=_next_public_ref(db, "DQ-SCAN"),
|
public_ref=_next_public_ref(db, "DQ-SCAN"),
|
||||||
rule_type=rule_type,
|
rule_type=rule_type,
|
||||||
@@ -80,11 +115,7 @@ def _open_issue(
|
|||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
severity=severity,
|
severity=severity,
|
||||||
status="open",
|
status="open",
|
||||||
evidence_json={
|
evidence_json=evidence,
|
||||||
"summary": summary,
|
|
||||||
"entity_ref": entity_ref,
|
|
||||||
"related_refs": related_refs,
|
|
||||||
},
|
|
||||||
proposed_action_json={},
|
proposed_action_json={},
|
||||||
detected_at=now,
|
detected_at=now,
|
||||||
)
|
)
|
||||||
@@ -102,22 +133,29 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
|||||||
for i, a in enumerate(customers):
|
for i, a in enumerate(customers):
|
||||||
for b in customers[i + 1 :]:
|
for b in customers[i + 1 :]:
|
||||||
score = 0
|
score = 0
|
||||||
signals = []
|
signals: list[dict] = []
|
||||||
|
summary_parts: list[str] = []
|
||||||
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
|
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
|
||||||
score += 60
|
score += 60
|
||||||
signals.append("exact email")
|
signals.append({"code": "duplicate.exact_email"})
|
||||||
|
summary_parts.append("exact email")
|
||||||
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
|
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
|
||||||
score += 50
|
score += 50
|
||||||
signals.append("exact phone")
|
signals.append({"code": "duplicate.exact_phone"})
|
||||||
|
summary_parts.append("exact phone")
|
||||||
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
|
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
|
||||||
score += 10
|
score += 10
|
||||||
signals.append("exact postal code")
|
signals.append({"code": "duplicate.same_postal_code"})
|
||||||
|
summary_parts.append("exact postal code")
|
||||||
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
|
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
|
||||||
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
|
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
|
||||||
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||||
if ratio >= 0.5:
|
if ratio >= 0.5:
|
||||||
score += round(ratio * 30)
|
score += round(ratio * 30)
|
||||||
signals.append("similar name")
|
signals.append(
|
||||||
|
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
|
||||||
|
)
|
||||||
|
summary_parts.append("similar name")
|
||||||
|
|
||||||
if score >= DUPLICATE_THRESHOLD:
|
if score >= DUPLICATE_THRESHOLD:
|
||||||
_open_issue(
|
_open_issue(
|
||||||
@@ -127,9 +165,10 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
|||||||
entity_type="customer",
|
entity_type="customer",
|
||||||
entity_id=a.id,
|
entity_id=a.id,
|
||||||
severity="high",
|
severity="high",
|
||||||
summary="; ".join(signals) + f" (score {score})",
|
summary="; ".join(summary_parts) + f" (score {score})",
|
||||||
entity_ref=a.public_ref,
|
entity_ref=a.public_ref,
|
||||||
related_refs=[b.public_ref],
|
related_refs=[b.public_ref],
|
||||||
|
signals=signals,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,6 +190,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
|
|||||||
summary=f"Missing: {', '.join(missing)}",
|
summary=f"Missing: {', '.join(missing)}",
|
||||||
entity_ref=customer.public_ref,
|
entity_ref=customer.public_ref,
|
||||||
related_refs=[],
|
related_refs=[],
|
||||||
|
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||||
)
|
)
|
||||||
|
|
||||||
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
|
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
|
||||||
@@ -166,6 +206,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
|
|||||||
summary=f"Missing: {', '.join(missing)}",
|
summary=f"Missing: {', '.join(missing)}",
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[],
|
related_refs=[],
|
||||||
|
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -194,50 +235,47 @@ def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
|
|||||||
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
|
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[first.public_ref, second.public_ref],
|
related_refs=[first.public_ref, second.public_ref],
|
||||||
|
signals=[
|
||||||
|
{
|
||||||
|
"code": "overlap.reserved_bookings",
|
||||||
|
"params": {"refs": [first.public_ref, second.public_ref]},
|
||||||
|
}
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
||||||
|
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
|
||||||
|
# so detection and resolution can never structurally disagree -- see
|
||||||
|
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
|
||||||
vehicles = db.scalars(select(Vehicle)).all()
|
vehicles = db.scalars(select(Vehicle)).all()
|
||||||
active_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
|
||||||
for booking in db.scalars(select(Booking).where(Booking.status == "active")).all():
|
|
||||||
active_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
|
||||||
|
|
||||||
open_high_by_vehicle = {
|
|
||||||
row[0]
|
|
||||||
for row in db.execute(
|
|
||||||
select(DataQualityIssue.entity_id).where(
|
|
||||||
DataQualityIssue.entity_type == "vehicle",
|
|
||||||
DataQualityIssue.status == "open",
|
|
||||||
DataQualityIssue.severity == "high",
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
}
|
|
||||||
|
|
||||||
for vehicle in vehicles:
|
for vehicle in vehicles:
|
||||||
has_active_booking = vehicle.id in active_by_vehicle
|
facts = gather_vehicle_status_facts(db, vehicle)
|
||||||
reason = None
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
if vehicle.operational_status == "available" and has_active_booking:
|
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
|
||||||
reason = "marked available while an active booking exists"
|
continue
|
||||||
elif vehicle.operational_status == "rented" and not has_active_booking:
|
|
||||||
reason = "marked rented without an active booking"
|
|
||||||
elif vehicle.operational_status == "available" and vehicle.id in open_high_by_vehicle:
|
|
||||||
reason = "marked available while a high-severity quality issue is open"
|
|
||||||
elif vehicle.operational_status == "maintenance" and has_active_booking:
|
|
||||||
reason = "marked maintenance while an active booking exists"
|
|
||||||
|
|
||||||
if reason:
|
signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
|
||||||
_open_issue(
|
summary = (
|
||||||
db,
|
f"Recommended status: {recommendation.recommended_status}"
|
||||||
scan,
|
if recommendation.recommended_status
|
||||||
rule_type="vehicle_status_conflict",
|
else "Manual review required: active rental conflicts with a blocking condition"
|
||||||
entity_type="vehicle",
|
)
|
||||||
entity_id=vehicle.id,
|
_open_issue(
|
||||||
severity="high",
|
db,
|
||||||
summary=f"Vehicle {reason}",
|
scan,
|
||||||
entity_ref=vehicle.public_ref,
|
rule_type="vehicle_status_conflict",
|
||||||
related_refs=[],
|
entity_type="vehicle",
|
||||||
)
|
entity_id=vehicle.id,
|
||||||
|
severity="high",
|
||||||
|
summary=summary,
|
||||||
|
entity_ref=vehicle.public_ref,
|
||||||
|
related_refs=[
|
||||||
|
*facts.active_booking_refs,
|
||||||
|
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
|
||||||
|
],
|
||||||
|
signals=signals,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||||
@@ -276,17 +314,39 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
|||||||
),
|
),
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[earlier.public_ref, later.public_ref],
|
related_refs=[earlier.public_ref, later.public_ref],
|
||||||
|
signals=[
|
||||||
|
{
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"params": {
|
||||||
|
"later_ref": later.public_ref,
|
||||||
|
"later_km": later.end_odometer_km,
|
||||||
|
"earlier_ref": earlier.public_ref,
|
||||||
|
"earlier_km": earlier.end_odometer_km,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
def run_scan(db: Session) -> ScanResult:
|
def run_scan(
|
||||||
|
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
|
||||||
|
) -> ScanResult:
|
||||||
scan = ScanResult()
|
scan = ScanResult()
|
||||||
_scan_duplicate_customers(db, scan)
|
_scan_duplicate_customers(db, scan)
|
||||||
_scan_missing_required_fields(db, scan)
|
_scan_missing_required_fields(db, scan)
|
||||||
_scan_odometer_regressions(db, scan)
|
_scan_odometer_regressions(db, scan)
|
||||||
_scan_booking_overlaps(db, scan)
|
_scan_booking_overlaps(db, scan)
|
||||||
_scan_vehicle_status_conflicts(db, scan)
|
_scan_vehicle_status_conflicts(db, scan)
|
||||||
|
if actor_label is not None:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_label=actor_label,
|
||||||
|
action="data_quality_scan_run",
|
||||||
|
entity_type="system",
|
||||||
|
metadata={"created": scan.created},
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return scan
|
return scan
|
||||||
|
|
||||||
@@ -344,6 +404,399 @@ def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualit
|
|||||||
return issue
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def provide_missing_fields(
|
||||||
|
db: Session, public_ref: str, fields: dict[str, str], actor: CurrentUser
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "missing_required_field":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE",
|
||||||
|
"This issue is not a missing-required-field issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
entity: Customer | Vehicle | None
|
||||||
|
if issue.entity_type == "customer":
|
||||||
|
entity = db.get(Customer, issue.entity_id)
|
||||||
|
allowed = {*REQUIRED_CUSTOMER_FIELDS, "email", "phone"}
|
||||||
|
elif issue.entity_type == "vehicle":
|
||||||
|
entity = db.get(Vehicle, issue.entity_id)
|
||||||
|
allowed = set(REQUIRED_VEHICLE_FIELDS)
|
||||||
|
else:
|
||||||
|
raise AppError(
|
||||||
|
"UNSUPPORTED_ENTITY",
|
||||||
|
f"Cannot provide fields for entity type '{issue.entity_type}'.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if entity is None:
|
||||||
|
raise AppError(
|
||||||
|
"ENTITY_NOT_FOUND", "The underlying record could not be found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid = set(fields) - allowed
|
||||||
|
if invalid:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_FIELD",
|
||||||
|
f"Fields not permitted here: {', '.join(sorted(invalid))}.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
if not fields:
|
||||||
|
raise AppError(
|
||||||
|
"NO_FIELDS_PROVIDED", "At least one field must be provided.", status_code=422
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {f: getattr(entity, f) for f in allowed}
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
if not value.strip():
|
||||||
|
raise AppError("EMPTY_VALUE", f"Field '{field_name}' cannot be blank.", status_code=422)
|
||||||
|
setattr(entity, field_name, value.strip())
|
||||||
|
after = {f: getattr(entity, f) for f in allowed}
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_fields_provided",
|
||||||
|
entity_type=issue.entity_type,
|
||||||
|
entity_id=entity.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
metadata={"issue_ref": issue.public_ref},
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(entity, Customer):
|
||||||
|
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(entity, f)]
|
||||||
|
if not entity.email and not entity.phone:
|
||||||
|
missing.append("email_or_phone")
|
||||||
|
else:
|
||||||
|
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(entity, f)]
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
issue.evidence_json = {**issue.evidence_json, "summary": f"Missing: {', '.join(missing)}"}
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_odometer_regression(
|
||||||
|
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "odometer_regression":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_AN_ODOMETER_ISSUE",
|
||||||
|
"This issue is not an odometer_regression issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if body.decision == "retain_canonical":
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_odometer_retained",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
related_refs = issue.evidence_json.get("related_refs", [])
|
||||||
|
if body.booking_ref not in related_refs:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"booking_ref must be one of this issue's related bookings.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
if body.corrected_odometer_km is None:
|
||||||
|
raise AppError(
|
||||||
|
"CORRECTED_VALUE_REQUIRED",
|
||||||
|
"corrected_odometer_km is required when correcting a reading.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
# Never silently lower the canonical odometer: a correction must be at or above
|
||||||
|
# the current canonical value, otherwise it would just create a new regression.
|
||||||
|
if body.corrected_odometer_km < vehicle.odometer_km:
|
||||||
|
raise AppError(
|
||||||
|
"CORRECTION_BELOW_CANONICAL",
|
||||||
|
(
|
||||||
|
f"Corrected value {body.corrected_odometer_km} km is still below the "
|
||||||
|
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
|
||||||
|
),
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
booking = db.scalar(
|
||||||
|
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||||
|
)
|
||||||
|
if booking is None:
|
||||||
|
raise AppError(
|
||||||
|
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {
|
||||||
|
"booking_end_odometer_km": booking.end_odometer_km,
|
||||||
|
"vehicle_odometer_km": vehicle.odometer_km,
|
||||||
|
}
|
||||||
|
booking.end_odometer_km = body.corrected_odometer_km
|
||||||
|
vehicle.odometer_km = body.corrected_odometer_km
|
||||||
|
vehicle.version += 1
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_odometer_corrected",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after={
|
||||||
|
"booking_end_odometer_km": booking.end_odometer_km,
|
||||||
|
"vehicle_odometer_km": vehicle.odometer_km,
|
||||||
|
},
|
||||||
|
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
|
||||||
|
)
|
||||||
|
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
metadata={"decision": body.decision, "note": body.note},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_booking_overlap(
|
||||||
|
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "booking_overlap":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_AN_OVERLAP_ISSUE", "This issue is not a booking_overlap issue.", status_code=409
|
||||||
|
)
|
||||||
|
related_refs = issue.evidence_json.get("related_refs", [])
|
||||||
|
if booking_ref not in related_refs:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"booking_ref must be one of this issue's overlapping bookings.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise AppError("BOOKING_NOT_FOUND", "The booking to block was not found.", status_code=404)
|
||||||
|
if booking.status not in ("reserved", "active"):
|
||||||
|
raise AppError(
|
||||||
|
"BOOKING_NOT_ACTIVE",
|
||||||
|
f"Booking is '{booking.status}'; only a reserved or active booking can be blocked.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {"status": booking.status}
|
||||||
|
booking.status = "blocked"
|
||||||
|
|
||||||
|
# Verify the minimal safe resolution actually removed the conflict: no two
|
||||||
|
# reserved/active bookings for this vehicle should still overlap. The session has
|
||||||
|
# autoflush disabled, so exclude the just-blocked booking by id rather than relying
|
||||||
|
# on the in-memory status change being visible to this query.
|
||||||
|
remaining = db.scalars(
|
||||||
|
select(Booking).where(
|
||||||
|
Booking.vehicle_id == booking.vehicle_id,
|
||||||
|
Booking.status.in_(["reserved", "active"]),
|
||||||
|
Booking.public_ref.in_(related_refs),
|
||||||
|
Booking.id != booking.id,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for i, first in enumerate(remaining):
|
||||||
|
for second in remaining[i + 1 :]:
|
||||||
|
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||||
|
raise AppError(
|
||||||
|
"OVERLAP_STILL_PRESENT",
|
||||||
|
"Blocking this booking did not remove the overlap; another commitment remains.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_booking_blocked",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after={"status": booking.status},
|
||||||
|
metadata={"issue_ref": issue.public_ref, "note": note},
|
||||||
|
)
|
||||||
|
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "vehicle_status_conflict":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||||
|
"This issue is not a vehicle_status_conflict issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def preview_vehicle_status_recommendation(
|
||||||
|
db: Session, public_ref: str
|
||||||
|
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
|
||||||
|
"""Non-mutating: computes and returns the recommendation only. Never resolves the
|
||||||
|
issue, never writes an audit event, never queues automation -- safe to call as often
|
||||||
|
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
|
||||||
|
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
|
token = compute_recommendation_token(vehicle, facts)
|
||||||
|
return issue, vehicle, recommendation, token
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recommended_status(
|
||||||
|
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
|
||||||
|
) -> tuple[DataQualityIssue, str, str]:
|
||||||
|
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||||
|
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
|
||||||
|
# (or return/checkout) can't race between our fact-gathering and the write below.
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
|
current_token = compute_recommendation_token(vehicle, facts)
|
||||||
|
|
||||||
|
if current_token != expected_token:
|
||||||
|
raise AppError(
|
||||||
|
"RECOMMENDATION_STALE",
|
||||||
|
"The underlying facts changed since this recommendation was shown; "
|
||||||
|
"review the recommendation again before applying it.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if recommendation.manual_review_required or not recommendation.safe_to_apply:
|
||||||
|
raise AppError(
|
||||||
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
|
"This vehicle's state requires manual review; no automatic status change is safe.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if recommendation.recommended_status is None:
|
||||||
|
raise AppError(
|
||||||
|
"NO_CONFLICT_DETECTED",
|
||||||
|
"The current vehicle state no longer conflicts; nothing to apply.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
new_status = recommendation.recommended_status
|
||||||
|
reason_code = recommendation.recommendation_code
|
||||||
|
|
||||||
|
before = {"operational_status": vehicle.operational_status}
|
||||||
|
vehicle.operational_status = new_status
|
||||||
|
vehicle.version += 1
|
||||||
|
|
||||||
|
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
|
||||||
|
# applying this change actually leaves no conflict -- never trust the pre-computed
|
||||||
|
# recommendation alone for the post-condition.
|
||||||
|
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
post_check = evaluate_vehicle_status(vehicle, post_facts)
|
||||||
|
if post_check.recommendation_code not in (
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
):
|
||||||
|
raise AppError(
|
||||||
|
"CONFLICT_STILL_PRESENT",
|
||||||
|
"Applying the recommended status did not resolve the conflict.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_status_applied",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after={"operational_status": vehicle.operational_status},
|
||||||
|
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue, new_status, reason_code
|
||||||
|
|
||||||
|
|
||||||
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||||
|
from app.services.integration_status import derive_n8n_status
|
||||||
|
from app.services.knowledge import get_knowledge_provider
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
|
||||||
|
|
||||||
|
|
||||||
|
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
|
||||||
|
marker = db.scalar(
|
||||||
|
select(AuditEvent)
|
||||||
|
.where(AuditEvent.action == "demo_data_seeded")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
)
|
||||||
|
if marker is None:
|
||||||
|
return None, None
|
||||||
|
metadata = marker.metadata_json or {}
|
||||||
|
return marker.occurred_at, metadata.get("anchor_date")
|
||||||
|
|
||||||
|
|
||||||
|
def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
|
||||||
|
duplicate_issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
|
||||||
|
)
|
||||||
|
overlap_issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
|
||||||
|
)
|
||||||
|
failed_run = db.scalar(
|
||||||
|
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
|
||||||
|
)
|
||||||
|
knowledge_health = get_knowledge_provider().health()
|
||||||
|
|
||||||
|
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
|
||||||
|
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
|
||||||
|
# languages. This service only emits stable identifiers and message codes -- never
|
||||||
|
# display prose -- per the message_code + params architecture used across the app.
|
||||||
|
return_ready = bool(
|
||||||
|
booking and booking.status == "active" and booking.end_odometer_km is None
|
||||||
|
)
|
||||||
|
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||||
|
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||||
|
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
|
||||||
|
|
||||||
|
return [
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="return-anomaly",
|
||||||
|
estimated_minutes=3,
|
||||||
|
required_roles=["rental_employee", "operations_manager"],
|
||||||
|
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
|
||||||
|
ready=return_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if return_ready
|
||||||
|
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="duplicate-customer",
|
||||||
|
estimated_minutes=3,
|
||||||
|
required_roles=["operations_manager"],
|
||||||
|
start_path=(
|
||||||
|
f"/data-quality/{duplicate_issue.public_ref}"
|
||||||
|
if duplicate_issue
|
||||||
|
else "/data-quality"
|
||||||
|
),
|
||||||
|
ready=duplicate_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if duplicate_ready
|
||||||
|
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="booking-overlap",
|
||||||
|
estimated_minutes=2,
|
||||||
|
required_roles=["operations_manager"],
|
||||||
|
start_path=(
|
||||||
|
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
|
||||||
|
),
|
||||||
|
ready=overlap_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if overlap_ready
|
||||||
|
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="automation-retry",
|
||||||
|
estimated_minutes=2,
|
||||||
|
required_roles=["operations_manager"],
|
||||||
|
start_path="/automation",
|
||||||
|
ready=automation_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if automation_ready
|
||||||
|
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="knowledge-question",
|
||||||
|
estimated_minutes=2,
|
||||||
|
required_roles=["rental_employee", "operations_manager"],
|
||||||
|
start_path="/knowledge",
|
||||||
|
ready=knowledge_health.available,
|
||||||
|
blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||||
|
n8n = derive_n8n_status(db)
|
||||||
|
knowledge_health = get_knowledge_provider().health()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DemoIntegrationSummaryOut(
|
||||||
|
key="n8n",
|
||||||
|
status_code=n8n.state,
|
||||||
|
detail_code="n8nDetail",
|
||||||
|
detail_params={
|
||||||
|
"succeeded": n8n.succeeded,
|
||||||
|
"failed": n8n.failed,
|
||||||
|
"pending": n8n.pending,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DemoIntegrationSummaryOut(
|
||||||
|
key="ragcore",
|
||||||
|
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
||||||
|
detail_code="ragcoreDetail",
|
||||||
|
detail_params={
|
||||||
|
"count": knowledge_health.document_count,
|
||||||
|
"collection": knowledge_health.collection,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DemoIntegrationSummaryOut(
|
||||||
|
key="mcp_hub",
|
||||||
|
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected",
|
||||||
|
detail_code=(
|
||||||
|
"mcpDetailEnabled"
|
||||||
|
if settings.mcp_hub_registration_enabled
|
||||||
|
else "mcpDetailNotConnected"
|
||||||
|
),
|
||||||
|
detail_params={},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_integrity_report(db: Session) -> dict:
|
||||||
|
"""Server-side scenario-integrity check run after every reset (section 15): confirms
|
||||||
|
each of the 5 named scenarios is actually present and ready, rather than trusting the
|
||||||
|
seed loader silently. Reuses the same readiness derivation the manifest/scenario
|
||||||
|
overview already use, so this can never drift from what a visitor actually sees."""
|
||||||
|
scenarios = _scenarios(db)
|
||||||
|
not_ready = [
|
||||||
|
{"id": s.id, "reason_code": s.blocked_reason_code}
|
||||||
|
for s in scenarios
|
||||||
|
if not s.ready
|
||||||
|
]
|
||||||
|
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
|
||||||
|
|
||||||
|
|
||||||
|
def build_demo_manifest(db: Session) -> DemoManifestOut:
|
||||||
|
last_reset_at, anchor_date = _last_reset(db)
|
||||||
|
return DemoManifestOut(
|
||||||
|
demo_mode=settings.mobilityops_demo_mode,
|
||||||
|
organization_name=settings.demo_organization_name,
|
||||||
|
timezone=settings.demo_timezone,
|
||||||
|
synthetic_data=True,
|
||||||
|
allow_reset=settings.demo_allow_reset,
|
||||||
|
last_reset_at=last_reset_at,
|
||||||
|
anchor_date=anchor_date,
|
||||||
|
guide_available=True,
|
||||||
|
required_roles=["operations_manager", "rental_employee"],
|
||||||
|
scenarios=_scenarios(db),
|
||||||
|
integrations=_integrations(db),
|
||||||
|
)
|
||||||
@@ -22,8 +22,43 @@ def _backoff_seconds(attempts: int) -> int:
|
|||||||
return min(2**attempts, 60)
|
return min(2**attempts, 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
|
||||||
|
"""Recover events stuck in 'delivering' because the process that claimed them died
|
||||||
|
before recording an outcome. Only leases whose deadline has passed are touched, so an
|
||||||
|
in-flight delivery from a still-alive worker is never disturbed or double-processed;
|
||||||
|
`attempts` is preserved so the count reflects true history."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(OutboxEvent)
|
||||||
|
.where(
|
||||||
|
OutboxEvent.delivery_status == "delivering",
|
||||||
|
OutboxEvent.next_attempt_at.is_not(None),
|
||||||
|
OutboxEvent.next_attempt_at <= now,
|
||||||
|
)
|
||||||
|
.limit(batch_size)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
row.delivery_status = "pending"
|
||||||
|
row.next_attempt_at = None
|
||||||
|
row.last_error = (
|
||||||
|
"Recovered from a stale 'delivering' lease "
|
||||||
|
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
||||||
|
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
||||||
|
)[:2000]
|
||||||
|
row.last_error_code = "staleLeaseRecovered"
|
||||||
|
db.commit()
|
||||||
|
return len(rows)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
||||||
"""Claim a batch of due events with a short-lived transaction (no network I/O held open)."""
|
"""Claim a batch of due events with a short-lived transaction (no network I/O held open).
|
||||||
|
Each claimed row gets a lease deadline (next_attempt_at) so a crash between this claim
|
||||||
|
and the outcome being recorded is recoverable by _reclaim_stale_deliveries."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
@@ -38,8 +73,10 @@ def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
|||||||
.with_for_update(skip_locked=True)
|
.with_for_update(skip_locked=True)
|
||||||
).all()
|
).all()
|
||||||
claimed_ids = [row.event_id for row in rows]
|
claimed_ids = [row.event_id for row in rows]
|
||||||
|
lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row.delivery_status = "delivering"
|
row.delivery_status = "delivering"
|
||||||
|
row.next_attempt_at = lease_deadline
|
||||||
db.commit()
|
db.commit()
|
||||||
return claimed_ids
|
return claimed_ids
|
||||||
finally:
|
finally:
|
||||||
@@ -75,22 +112,43 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
error_code: str | None
|
||||||
if wire_event is None:
|
if wire_event is None:
|
||||||
success, error, body = False, payload_error, None
|
success, error, body = False, payload_error, None
|
||||||
|
error_code = "malformedPayload"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
settings.n8n_webhook_url,
|
settings.n8n_webhook_url,
|
||||||
json=wire_event,
|
json=wire_event,
|
||||||
|
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
|
||||||
timeout=settings.n8n_http_timeout_seconds,
|
timeout=settings.n8n_http_timeout_seconds,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
body = response.json()
|
try:
|
||||||
success = bool(body.get("ok", True))
|
body = response.json()
|
||||||
error = None if success else f"n8n reported failure: {body}"
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
if isinstance(body, dict):
|
||||||
|
success = bool(body.get("ok", True))
|
||||||
|
error = None if success else f"n8n reported failure: {body}"
|
||||||
|
error_code = None if success else "remoteReportedFailure"
|
||||||
|
else:
|
||||||
|
# A 2xx status with a non-object (or unparsable) body means the workflow
|
||||||
|
# itself errored before its "Respond to Webhook" node ran -- n8n's default
|
||||||
|
# error response still carries a 2xx-looking status here. Treat it as a
|
||||||
|
# failure so the event is retried rather than lost or wrongly marked
|
||||||
|
# succeeded.
|
||||||
|
success = False
|
||||||
|
error = (
|
||||||
|
"Unexpected non-JSON-object response from n8n "
|
||||||
|
f"(status {response.status_code})"
|
||||||
|
)
|
||||||
|
error_code = "malformedResponse"
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
success = False
|
success = False
|
||||||
error = f"{type(exc).__name__}: {exc}"
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
error_code = "connectionError"
|
||||||
body = None
|
body = None
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -102,10 +160,12 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
if success:
|
if success:
|
||||||
event.delivery_status = "succeeded"
|
event.delivery_status = "succeeded"
|
||||||
event.last_error = None
|
event.last_error = None
|
||||||
|
event.last_error_code = None
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
event.external_run_id = str((body or {}).get("event_id", event_id))
|
event.external_run_id = str((body or {}).get("event_id", event_id))
|
||||||
else:
|
else:
|
||||||
event.last_error = (error or "delivery failed")[:2000]
|
event.last_error = (error or "delivery failed")[:2000]
|
||||||
|
event.last_error_code = error_code or "unknownError"
|
||||||
if event.attempts >= settings.n8n_max_attempts:
|
if event.attempts >= settings.n8n_max_attempts:
|
||||||
event.delivery_status = "failed"
|
event.delivery_status = "failed"
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
@@ -120,7 +180,8 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def run_dispatch_cycle() -> int:
|
def run_dispatch_cycle() -> int:
|
||||||
"""Run one claim+deliver cycle. Returns the number of events processed."""
|
"""Run one reclaim+claim+deliver cycle. Returns the number of events processed."""
|
||||||
|
_reclaim_stale_deliveries()
|
||||||
claimed = _claim_due_events()
|
claimed = _claim_due_events()
|
||||||
for event_id in claimed:
|
for event_id in claimed:
|
||||||
_deliver_one(event_id)
|
_deliver_one(event_id)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). Workflow 3
|
||||||
|
# (RAGcore Procedure Sync) is not built yet, so it always reports no evidence.
|
||||||
|
_CANONICAL_WORKFLOWS = (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||||
|
counts: dict[str, int] = dict(
|
||||||
|
db.execute(
|
||||||
|
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||||
|
).all() # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
pending = counts.get("pending", 0)
|
||||||
|
delivering = counts.get("delivering", 0)
|
||||||
|
failed = counts.get("failed", 0)
|
||||||
|
succeeded = counts.get("succeeded", 0)
|
||||||
|
|
||||||
|
latest_success_at = db.scalar(
|
||||||
|
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||||
|
)
|
||||||
|
latest_failure_at = db.scalar(
|
||||||
|
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||||
|
if not settings.n8n_dispatch_enabled:
|
||||||
|
state = "disabled"
|
||||||
|
elif failed > 0 and succeeded == 0:
|
||||||
|
state = "unavailable"
|
||||||
|
elif failed > 0:
|
||||||
|
state = "degraded"
|
||||||
|
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||||
|
state = "operational"
|
||||||
|
else:
|
||||||
|
state = "no_evidence"
|
||||||
|
|
||||||
|
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
|
||||||
|
# runs an operator triggered manually from the Data Quality page.
|
||||||
|
latest_scan_at = db.scalar(
|
||||||
|
select(func.max(AuditEvent.occurred_at)).where(
|
||||||
|
AuditEvent.action == "data_quality_scan_run",
|
||||||
|
AuditEvent.actor_type == "service",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||||
|
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
|
||||||
|
# up and firing correctly.
|
||||||
|
total_failures_registered = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(AuditEvent.id)).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
latest_failure_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.after_json)
|
||||||
|
.where(AuditEvent.action == "n8n_workflow_failure_registered")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
|
||||||
|
latest_handler_failure_workflow = (
|
||||||
|
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence_by_workflow = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": None,
|
||||||
|
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||||
|
}
|
||||||
|
workflows = [
|
||||||
|
N8nWorkflowEvidence(
|
||||||
|
name=name,
|
||||||
|
built=name != "Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
last_seen_at=evidence_by_workflow[name],
|
||||||
|
)
|
||||||
|
for name in _CANONICAL_WORKFLOWS
|
||||||
|
]
|
||||||
|
|
||||||
|
return N8nIntegrationStatus(
|
||||||
|
configured=bool(settings.n8n_webhook_url),
|
||||||
|
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||||
|
state=state,
|
||||||
|
pending=pending,
|
||||||
|
delivering=delivering,
|
||||||
|
failed=failed,
|
||||||
|
succeeded=succeeded,
|
||||||
|
latest_success_at=latest_success_at,
|
||||||
|
latest_failure_at=latest_failure_at,
|
||||||
|
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
|
||||||
|
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
|
||||||
|
workflows=workflows,
|
||||||
|
error_handler=N8nErrorHandlerStatus(
|
||||||
|
total_failures_registered=total_failures_registered,
|
||||||
|
latest_failure_at=latest_handler_failure_at,
|
||||||
|
latest_failure_workflow=latest_handler_failure_workflow,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel):
|
|||||||
class KnowledgeProvider(Protocol):
|
class KnowledgeProvider(Protocol):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
def health(self) -> KnowledgeHealth: ...
|
def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
|
||||||
|
|
||||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: ...
|
def ask(
|
||||||
|
self, question: str, correlation_id: str, language: str = "en-GB"
|
||||||
|
) -> GroundedAnswer: ...
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|||||||
@@ -7,16 +7,40 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
from app.services.knowledge.procedures import parse_frontmatter
|
||||||
|
|
||||||
STOPWORDS = {
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
DEFAULT_LANGUAGE = "en-GB"
|
||||||
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
|
|
||||||
"do", "does", "did", "must", "may", "can", "could", "should", "would",
|
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
||||||
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
|
"en-GB": {
|
||||||
"with", "without", "this", "that", "these", "those", "not", "no",
|
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
||||||
|
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
|
||||||
|
"do", "does", "did", "must", "may", "can", "could", "should", "would",
|
||||||
|
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
|
||||||
|
"with", "without", "this", "that", "these", "those", "not", "no",
|
||||||
|
},
|
||||||
|
"nl-BE": {
|
||||||
|
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt",
|
||||||
|
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan",
|
||||||
|
"moet", "mag", "kan", "kunnen", "zou", "zouden",
|
||||||
|
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe",
|
||||||
|
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen",
|
||||||
|
},
|
||||||
|
"fr-BE": {
|
||||||
|
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être",
|
||||||
|
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors",
|
||||||
|
"doit", "peut", "peuvent", "pourrait", "devrait",
|
||||||
|
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment",
|
||||||
|
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_WORD_RE = re.compile(r"[a-z0-9]+")
|
# Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with
|
||||||
|
# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of
|
||||||
|
# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently
|
||||||
|
# drops every accent and fragments the word either side of it.
|
||||||
|
_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+")
|
||||||
|
|
||||||
|
|
||||||
def _stem(word: str) -> str:
|
def _stem(word: str) -> str:
|
||||||
@@ -28,9 +52,10 @@ def _stem(word: str) -> str:
|
|||||||
return word
|
return word
|
||||||
|
|
||||||
|
|
||||||
def _tokenize(text: str) -> set[str]:
|
def _tokenize(text: str, language: str) -> set[str]:
|
||||||
|
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
|
||||||
words = _WORD_RE.findall(text.lower())
|
words = _WORD_RE.findall(text.lower())
|
||||||
return {_stem(w) for w in words if w not in STOPWORDS and len(w) > 2}
|
return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -50,23 +75,6 @@ class ScoredSection:
|
|||||||
body_tokens: set[str]
|
body_tokens: set[str]
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
|
||||||
if not raw.startswith("---"):
|
|
||||||
return {}, raw
|
|
||||||
end = raw.find("\n---", 3)
|
|
||||||
if end == -1:
|
|
||||||
return {}, raw
|
|
||||||
block = raw[3:end].strip()
|
|
||||||
body = raw[end + 4 :].lstrip("\n")
|
|
||||||
meta: dict[str, str] = {}
|
|
||||||
for line in block.splitlines():
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition(":")
|
|
||||||
meta[key.strip()] = value.strip().strip('"')
|
|
||||||
return meta, body
|
|
||||||
|
|
||||||
|
|
||||||
def _split_sections(body: str) -> list[tuple[str, str]]:
|
def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||||
sections: list[tuple[str, str]] = []
|
sections: list[tuple[str, str]] = []
|
||||||
current_heading = "Overview"
|
current_heading = "Overview"
|
||||||
@@ -86,17 +94,17 @@ def _split_sections(body: str) -> list[tuple[str, str]]:
|
|||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
||||||
sections: list[ScoredSection] = []
|
sections: list[ScoredSection] = []
|
||||||
for path in sorted(procedures_dir.glob("*.md")):
|
for path in sorted(procedures_dir.glob("*.md")):
|
||||||
raw = path.read_text(encoding="utf-8")
|
raw = path.read_text(encoding="utf-8")
|
||||||
meta, body = _parse_frontmatter(raw)
|
meta, body = parse_frontmatter(raw)
|
||||||
title = meta.get("title", path.stem)
|
title = meta.get("title", path.stem)
|
||||||
doc = Document(
|
doc = Document(
|
||||||
document_id=meta.get("document_id", path.stem),
|
document_id=meta.get("document_id", path.stem),
|
||||||
title=title,
|
title=title,
|
||||||
version=meta.get("version", "1.0"),
|
version=meta.get("version", "1.0"),
|
||||||
title_tokens=_tokenize(title),
|
title_tokens=_tokenize(title, language),
|
||||||
)
|
)
|
||||||
for heading, text in _split_sections(body):
|
for heading, text in _split_sections(body):
|
||||||
sections.append(
|
sections.append(
|
||||||
@@ -104,19 +112,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
|||||||
document=doc,
|
document=doc,
|
||||||
heading=heading,
|
heading=heading,
|
||||||
text=text,
|
text=text,
|
||||||
heading_tokens=_tokenize(heading),
|
heading_tokens=_tokenize(heading, language),
|
||||||
body_tokens=_tokenize(text),
|
body_tokens=_tokenize(text, language),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
_NO_MATCH_TEXT = {
|
||||||
|
"en-GB": "No matching procedure was found for this question.",
|
||||||
|
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
|
||||||
|
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
|
||||||
|
}
|
||||||
|
_LOW_CONFIDENCE_TEXT = {
|
||||||
|
"en-GB": (
|
||||||
|
"The available procedures do not clearly answer this question. "
|
||||||
|
"The closest matches are included below for review."
|
||||||
|
),
|
||||||
|
"nl-BE": (
|
||||||
|
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
|
||||||
|
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
|
||||||
|
),
|
||||||
|
"fr-BE": (
|
||||||
|
"Les procédures disponibles ne répondent pas clairement à cette question. "
|
||||||
|
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_LEAD_ANSWER_TEMPLATE = {
|
||||||
|
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
|
||||||
|
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
|
||||||
|
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DemoKnowledgeProvider:
|
class DemoKnowledgeProvider:
|
||||||
"""Deterministic extractive retrieval over the local procedure Markdown files.
|
"""Deterministic extractive retrieval over the local procedure Markdown files.
|
||||||
|
|
||||||
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
|
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
|
||||||
(downweighting terms common across the whole corpus, like "vehicle", in favor of
|
(downweighting terms common across the whole corpus, like "vehicle", in favor of
|
||||||
distinctive ones, like "damage") and returns real excerpts, never invented text.
|
distinctive ones, like "damage") and returns real excerpts, never invented text.
|
||||||
|
|
||||||
|
Each supported UI language has its own translated procedure corpus under
|
||||||
|
knowledge/procedures/<language>/ -- retrieval searches only within the requested
|
||||||
|
language's corpus so citations always link to a same-language document.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "demo"
|
name = "demo"
|
||||||
@@ -124,10 +162,18 @@ class DemoKnowledgeProvider:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._procedures_dir = Path(settings.knowledge_dir)
|
base_dir = Path(settings.knowledge_dir)
|
||||||
self._sections = _load_sections(self._procedures_dir)
|
self._sections_by_language: dict[str, list[ScoredSection]] = {}
|
||||||
self._document_count = len({s.document.document_id for s in self._sections})
|
self._idf_by_language: dict[str, dict[str, float]] = {}
|
||||||
self._idf = self._build_idf(self._sections)
|
self._document_count_by_language: dict[str, int] = {}
|
||||||
|
for language in SUPPORTED_LANGUAGES:
|
||||||
|
lang_dir = base_dir / language
|
||||||
|
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
|
||||||
|
self._sections_by_language[language] = sections
|
||||||
|
self._idf_by_language[language] = self._build_idf(sections)
|
||||||
|
self._document_count_by_language[language] = len(
|
||||||
|
{s.document.document_id for s in sections}
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
|
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
|
||||||
@@ -140,7 +186,13 @@ class DemoKnowledgeProvider:
|
|||||||
doc_freq[token] = doc_freq.get(token, 0) + 1
|
doc_freq[token] = doc_freq.get(token, 0) + 1
|
||||||
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
|
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
|
||||||
|
|
||||||
def health(self) -> KnowledgeHealth:
|
def _normalize_language(self, language: str | None) -> str:
|
||||||
|
if language in SUPPORTED_LANGUAGES:
|
||||||
|
return language
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
|
||||||
|
language = self._normalize_language(language)
|
||||||
return KnowledgeHealth(
|
return KnowledgeHealth(
|
||||||
provider=self.name,
|
provider=self.name,
|
||||||
available=True,
|
available=True,
|
||||||
@@ -148,36 +200,51 @@ class DemoKnowledgeProvider:
|
|||||||
tenant=self._settings.ragcore_tenant,
|
tenant=self._settings.ragcore_tenant,
|
||||||
workspace=self._settings.ragcore_workspace,
|
workspace=self._settings.ragcore_workspace,
|
||||||
collection=self._settings.ragcore_collection,
|
collection=self._settings.ragcore_collection,
|
||||||
document_count=self._document_count,
|
document_count=self._document_count_by_language[language],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _score(self, query_tokens: set[str], section: ScoredSection) -> float:
|
def _score(
|
||||||
|
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
||||||
|
) -> float:
|
||||||
|
# The section body is the strongest relevance signal -- it's the actual
|
||||||
|
# substance a heading or title can only hint at -- so a body match is weighted
|
||||||
|
# *above* heading/title matches, not below them. The previous 3x/2x/1x
|
||||||
|
# (heading/title/body) ordering let a single generic word in a heading (e.g.
|
||||||
|
# "vehicle", present in nearly every section) or a document's own title
|
||||||
|
# outrank a section whose body genuinely covers multiple, more distinctive
|
||||||
|
# query terms -- confirmed to misrank the brief's exact validation question in
|
||||||
|
# every one of the three languages (see docs/fleet-ops-correction/
|
||||||
|
# current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section
|
||||||
|
# over the damage procedure, en-GB and fr-BE picked the return procedure over
|
||||||
|
# the damage procedure, purely from heading/title overlap on common words.
|
||||||
score = 0.0
|
score = 0.0
|
||||||
for token in query_tokens:
|
for token in query_tokens:
|
||||||
idf = self._idf.get(token, 0.0)
|
token_idf = idf.get(token, 0.0)
|
||||||
if idf == 0.0:
|
if token_idf == 0.0:
|
||||||
continue
|
continue
|
||||||
if token in section.heading_tokens:
|
if token in section.body_tokens:
|
||||||
score += 3 * idf
|
score += 3 * token_idf
|
||||||
|
elif token in section.heading_tokens:
|
||||||
|
score += 2 * token_idf
|
||||||
elif token in section.document.title_tokens:
|
elif token in section.document.title_tokens:
|
||||||
score += 2 * idf
|
score += 1.5 * token_idf
|
||||||
elif token in section.body_tokens:
|
|
||||||
score += idf
|
|
||||||
return score
|
return score
|
||||||
|
|
||||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
|
def ask(
|
||||||
query_tokens = _tokenize(question)
|
self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
|
||||||
scored = [
|
) -> GroundedAnswer:
|
||||||
(self._score(query_tokens, section), section)
|
language = self._normalize_language(language)
|
||||||
for section in self._sections
|
sections = self._sections_by_language[language]
|
||||||
]
|
idf = self._idf_by_language[language]
|
||||||
|
query_tokens = _tokenize(question, language)
|
||||||
|
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
|
||||||
scored = [(score, section) for score, section in scored if score > 0]
|
scored = [(score, section) for score, section in scored if score > 0]
|
||||||
scored.sort(key=lambda item: item[0], reverse=True)
|
scored.sort(key=lambda item: item[0], reverse=True)
|
||||||
top = scored[:3]
|
top = scored[:3]
|
||||||
|
|
||||||
if not top:
|
if not top:
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer="No matching procedure was found for this question.",
|
answer=_NO_MATCH_TEXT[language],
|
||||||
evidence_state="insufficient",
|
evidence_state="insufficient",
|
||||||
sources=[],
|
sources=[],
|
||||||
provider=self.name,
|
provider=self.name,
|
||||||
@@ -197,10 +264,7 @@ class DemoKnowledgeProvider:
|
|||||||
|
|
||||||
if top[0][0] < 3:
|
if top[0][0] < 3:
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer=(
|
answer=_LOW_CONFIDENCE_TEXT[language],
|
||||||
"The available procedures do not clearly answer this question. "
|
|
||||||
"The closest matches are included below for review."
|
|
||||||
),
|
|
||||||
evidence_state="insufficient",
|
evidence_state="insufficient",
|
||||||
sources=sources,
|
sources=sources,
|
||||||
provider=self.name,
|
provider=self.name,
|
||||||
@@ -208,9 +272,11 @@ class DemoKnowledgeProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
lead_section = top[0][1]
|
lead_section = top[0][1]
|
||||||
answer = (
|
answer = _LEAD_ANSWER_TEMPLATE[language].format(
|
||||||
f'Per "{lead_section.document.title}" (v{lead_section.document.version}), '
|
title=lead_section.document.title,
|
||||||
f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}'
|
version=lead_section.document.version,
|
||||||
|
heading=lead_section.heading,
|
||||||
|
excerpt=lead_section.text.splitlines()[0][:300],
|
||||||
)
|
)
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer=answer,
|
answer=answer,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
|
|
||||||
|
# Stable across runs (and across which language ships first) so a document's RAGcore
|
||||||
|
# source_id never changes just because the sync ran on a different day or in a
|
||||||
|
# different order -- required for RAGcore's upload idempotency to work per document.
|
||||||
|
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||||
|
if not raw.startswith("---"):
|
||||||
|
return {}, raw
|
||||||
|
end = raw.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}, raw
|
||||||
|
block = raw[3:end].strip()
|
||||||
|
body = raw[end + 4 :].lstrip("\n")
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for line in block.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
meta[key.strip()] = value.strip().strip('"')
|
||||||
|
return meta, body
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcedureDocument:
|
||||||
|
source_id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
|
||||||
|
"""Read every procedure Markdown file Fleet Ops ships, across every supported
|
||||||
|
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
|
||||||
|
fields (title, version) come from the same files the demo knowledge provider
|
||||||
|
already reads -- see parse_frontmatter -- so the two never drift apart."""
|
||||||
|
|
||||||
|
documents: list[ProcedureDocument] = []
|
||||||
|
for language in SUPPORTED_LANGUAGES:
|
||||||
|
language_dir = knowledge_dir / language
|
||||||
|
if not language_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for path in sorted(language_dir.glob("*.md")):
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
meta, body = parse_frontmatter(raw)
|
||||||
|
document_id = meta.get("document_id", path.stem)
|
||||||
|
content = body.strip()
|
||||||
|
documents.append(
|
||||||
|
ProcedureDocument(
|
||||||
|
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
|
||||||
|
language=language,
|
||||||
|
document_id=document_id,
|
||||||
|
title=meta.get("title", path.stem),
|
||||||
|
version=meta.get("version", "1.0"),
|
||||||
|
content=content,
|
||||||
|
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return documents
|
||||||
@@ -3,18 +3,29 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
|
||||||
|
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||||
|
|
||||||
|
|
||||||
class RAGcoreKnowledgeProvider:
|
class RAGcoreKnowledgeProvider:
|
||||||
"""Adapter for the central RAGcore service.
|
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
|
||||||
|
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
|
||||||
|
owned separately, MobilityOps only ever talks to its documented HTTP API).
|
||||||
|
|
||||||
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md).
|
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
|
||||||
No live RAGcore instance was reachable during this build, so the exact request/response
|
session-cookie auth is for its own browser admin UI only). Any connection error,
|
||||||
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
|
timeout, non-2xx response, or malformed body degrades to `evidence_state:
|
||||||
malformed response) degrades to `unavailable` rather than raising, per the architecture's
|
"unavailable"` rather than raising -- this is the adapter that actually exercises the
|
||||||
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
|
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
|
||||||
the app, and never fabricates an answer.
|
never fabricates an answer, never affects the rest of the app.
|
||||||
|
|
||||||
|
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
|
||||||
|
chunk's `language` payload field as `"en"` regardless of actual document language (the
|
||||||
|
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
|
||||||
|
Filtering search/answer requests by requested UI language would therefore silently
|
||||||
|
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
|
||||||
|
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "ragcore"
|
name = "ragcore"
|
||||||
@@ -32,14 +43,18 @@ class RAGcoreKnowledgeProvider:
|
|||||||
timeout=self._settings.ragcore_http_timeout_seconds,
|
timeout=self._settings.ragcore_http_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
def health(self) -> KnowledgeHealth:
|
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.get("/health")
|
response = client.get("/health/ready")
|
||||||
response.raise_for_status()
|
body = response.json()
|
||||||
available = True
|
available = response.status_code == 200 and body.get("status") == "ok"
|
||||||
detail = "RAGcore reachable."
|
detail = (
|
||||||
except httpx.HTTPError as exc:
|
"RAGcore reachable and ready."
|
||||||
|
if available
|
||||||
|
else f"RAGcore degraded: {body.get('status', 'unknown')}"
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
available = False
|
available = False
|
||||||
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||||
return KnowledgeHealth(
|
return KnowledgeHealth(
|
||||||
@@ -49,50 +64,58 @@ class RAGcoreKnowledgeProvider:
|
|||||||
tenant=self._settings.ragcore_tenant,
|
tenant=self._settings.ragcore_tenant,
|
||||||
workspace=self._settings.ragcore_workspace,
|
workspace=self._settings.ragcore_workspace,
|
||||||
collection=self._settings.ragcore_collection,
|
collection=self._settings.ragcore_collection,
|
||||||
|
# RAGcore's retrieval API has no corpus-size endpoint to query honestly from
|
||||||
|
# here; left at 0 rather than approximated from a capped search result count.
|
||||||
document_count=0,
|
document_count=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
|
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||||
|
unavailable = GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="unavailable",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
if not self._settings.ragcore_space_id:
|
||||||
|
return unavailable
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client() as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/ask",
|
"/v1/answers",
|
||||||
json={
|
json={
|
||||||
"tenant": self._settings.ragcore_tenant,
|
"query": question,
|
||||||
"workspace": self._settings.ragcore_workspace,
|
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||||
"collection": self._settings.ragcore_collection,
|
|
||||||
"question": question,
|
|
||||||
"correlation_id": correlation_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if response.status_code != 200:
|
||||||
|
return unavailable
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except (httpx.HTTPError, ValueError):
|
except (httpx.HTTPError, ValueError):
|
||||||
return GroundedAnswer(
|
return unavailable
|
||||||
answer="",
|
|
||||||
evidence_state="unavailable",
|
|
||||||
sources=[],
|
|
||||||
provider=self.name,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sources = [SourceCard(**s) for s in body.get("sources", [])]
|
citations = {c["id"]: c for c in body.get("citations", [])}
|
||||||
evidence_state = body.get("evidence_state", "insufficient")
|
sources = [
|
||||||
if evidence_state not in ("grounded", "insufficient", "unavailable"):
|
SourceCard(
|
||||||
evidence_state = "insufficient"
|
document_id=str(citation["document_id"]),
|
||||||
|
title=citation["title"],
|
||||||
|
version=str(citation["document_version_id"]),
|
||||||
|
section=citation.get("section") or "",
|
||||||
|
excerpt=citation["excerpt"],
|
||||||
|
)
|
||||||
|
for citation in citations.values()
|
||||||
|
]
|
||||||
|
answerability = body.get("answerability", "not_answerable")
|
||||||
|
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
||||||
|
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||||
return GroundedAnswer(
|
return GroundedAnswer(
|
||||||
answer=body.get("answer", ""),
|
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||||
evidence_state=evidence_state,
|
evidence_state=evidence_state,
|
||||||
sources=sources,
|
sources=sources if evidence_state == "grounded" else [],
|
||||||
provider=self.name,
|
|
||||||
correlation_id=correlation_id,
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return GroundedAnswer(
|
|
||||||
answer="",
|
|
||||||
evidence_state="unavailable",
|
|
||||||
sources=[],
|
|
||||||
provider=self.name,
|
provider=self.name,
|
||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
return unavailable
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -25,12 +26,147 @@ def _next_public_ref(db: Session) -> str:
|
|||||||
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
|
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
|
||||||
|
|
||||||
|
|
||||||
def _derive_vehicle_status(body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int) -> str:
|
def _derive_vehicle_status_with_reason(
|
||||||
if body.damage_reported or body.technical_warning:
|
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
||||||
return "blocked"
|
) -> tuple[str, str, dict[str, str | int]]:
|
||||||
|
# Stable, localizable codes + params -- the backend never emits prose here. The
|
||||||
|
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
|
||||||
|
# raw-English fallback strings live only in status_reason (shown under "Technical
|
||||||
|
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
|
||||||
|
if body.damage_reported and body.technical_warning:
|
||||||
|
return (
|
||||||
|
"blocked",
|
||||||
|
"returnBlockedDamageAndTechnical",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if body.damage_reported:
|
||||||
|
return "blocked", "returnBlockedDamage", {}
|
||||||
|
if body.technical_warning:
|
||||||
|
return "blocked", "returnBlockedTechnicalWarning", {}
|
||||||
if new_odometer >= vehicle.next_service_km:
|
if new_odometer >= vehicle.next_service_km:
|
||||||
return "maintenance"
|
return (
|
||||||
return "cleaning"
|
"maintenance",
|
||||||
|
"returnServiceThresholdReached",
|
||||||
|
{"threshold_km": vehicle.next_service_km},
|
||||||
|
)
|
||||||
|
return "cleaning", "returnRoutedToCleaning", {}
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
|
||||||
|
"returnBlockedDamageAndTechnical": (
|
||||||
|
"Damage and a technical warning were both reported on return."
|
||||||
|
),
|
||||||
|
"returnBlockedDamage": "Damage was reported on return.",
|
||||||
|
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
|
||||||
|
"returnServiceThresholdReached": "Odometer reached the service threshold.",
|
||||||
|
"returnRoutedToCleaning": (
|
||||||
|
"No damage, technical warning or service threshold; routed to cleaning."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReturnEvaluation:
|
||||||
|
canonical_odometer_km: int
|
||||||
|
submitted_odometer_km: int
|
||||||
|
odometer_regression: bool
|
||||||
|
resulting_odometer_km: int
|
||||||
|
resulting_vehicle_status: str
|
||||||
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int]
|
||||||
|
would_create_quality_issue: bool
|
||||||
|
attention_reasons: list[str]
|
||||||
|
next_booking_risk: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_return(
|
||||||
|
db: Session, booking: Booking, vehicle: Vehicle, body: RegisterReturnRequest, *, now: datetime
|
||||||
|
) -> ReturnEvaluation:
|
||||||
|
"""Pure evaluation of what a return would do. No writes; safe to call from a
|
||||||
|
non-mutating preview endpoint. `register_vehicle_return` uses the same function so
|
||||||
|
preview and commit can never drift apart."""
|
||||||
|
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||||
|
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
||||||
|
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
|
||||||
|
body, vehicle, resulting_odometer_km
|
||||||
|
)
|
||||||
|
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
|
||||||
|
|
||||||
|
attention_reasons = []
|
||||||
|
if body.damage_reported:
|
||||||
|
attention_reasons.append("damage_reported")
|
||||||
|
if body.technical_warning:
|
||||||
|
attention_reasons.append("technical_warning")
|
||||||
|
if odometer_regression:
|
||||||
|
attention_reasons.append("odometer_regression")
|
||||||
|
|
||||||
|
next_booking = db.scalar(
|
||||||
|
select(Booking)
|
||||||
|
.where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status == "reserved",
|
||||||
|
Booking.starts_at > now,
|
||||||
|
)
|
||||||
|
.order_by(Booking.starts_at.asc())
|
||||||
|
)
|
||||||
|
next_booking_risk = None
|
||||||
|
if next_booking is not None:
|
||||||
|
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
|
||||||
|
next_booking_risk = {
|
||||||
|
"booking_ref": next_booking.public_ref,
|
||||||
|
"starts_at": next_booking.starts_at.isoformat(),
|
||||||
|
"at_risk": resulting_status != "cleaning" or hours_until < 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReturnEvaluation(
|
||||||
|
canonical_odometer_km=vehicle.odometer_km,
|
||||||
|
submitted_odometer_km=body.end_odometer_km,
|
||||||
|
odometer_regression=odometer_regression,
|
||||||
|
resulting_odometer_km=resulting_odometer_km,
|
||||||
|
resulting_vehicle_status=resulting_status,
|
||||||
|
status_reason=status_reason,
|
||||||
|
status_reason_code=status_reason_code,
|
||||||
|
status_reason_params=status_reason_params,
|
||||||
|
would_create_quality_issue=odometer_regression,
|
||||||
|
attention_reasons=attention_reasons,
|
||||||
|
next_booking_risk=next_booking_risk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_active_booking_and_vehicle(
|
||||||
|
db: Session, booking_ref: str, *, lock: bool
|
||||||
|
) -> tuple[Booking, Vehicle]:
|
||||||
|
stmt = select(Booking).where(Booking.public_ref == booking_ref)
|
||||||
|
if lock:
|
||||||
|
stmt = stmt.with_for_update()
|
||||||
|
booking = db.scalar(stmt)
|
||||||
|
if booking is None:
|
||||||
|
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
|
||||||
|
|
||||||
|
vehicle_stmt = select(Vehicle).where(Vehicle.id == booking.vehicle_id)
|
||||||
|
if lock:
|
||||||
|
vehicle_stmt = vehicle_stmt.with_for_update()
|
||||||
|
vehicle = db.scalar(vehicle_stmt)
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
|
||||||
|
)
|
||||||
|
return booking, vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def preview_vehicle_return(
|
||||||
|
db: Session, booking_ref: str, body: RegisterReturnRequest
|
||||||
|
) -> tuple[Booking, Vehicle, ReturnEvaluation]:
|
||||||
|
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=False)
|
||||||
|
if booking.status != "active":
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_STATE",
|
||||||
|
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
evaluation = evaluate_return(db, booking, vehicle, body, now=datetime.now(UTC))
|
||||||
|
return booking, vehicle, evaluation
|
||||||
|
|
||||||
|
|
||||||
def register_vehicle_return(
|
def register_vehicle_return(
|
||||||
@@ -53,14 +189,7 @@ def register_vehicle_return(
|
|||||||
)
|
)
|
||||||
return existing.response_status, existing.response_body
|
return existing.response_status, existing.response_body
|
||||||
|
|
||||||
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
|
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||||
if booking is None:
|
|
||||||
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
|
|
||||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
|
||||||
if vehicle is None:
|
|
||||||
raise AppError(
|
|
||||||
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-check after acquiring the row lock: a concurrent identical-key request may have
|
# Re-check after acquiring the row lock: a concurrent identical-key request may have
|
||||||
# just committed while we were waiting.
|
# just committed while we were waiting.
|
||||||
@@ -79,6 +208,7 @@ def register_vehicle_return(
|
|||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
correlation_id = uuid.uuid4()
|
correlation_id = uuid.uuid4()
|
||||||
|
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
|
||||||
|
|
||||||
inspection = Inspection(
|
inspection = Inspection(
|
||||||
public_ref=_next_public_ref(db),
|
public_ref=_next_public_ref(db),
|
||||||
@@ -104,13 +234,8 @@ def register_vehicle_return(
|
|||||||
booking.status = "returned"
|
booking.status = "returned"
|
||||||
booking.end_odometer_km = body.end_odometer_km
|
booking.end_odometer_km = body.end_odometer_km
|
||||||
|
|
||||||
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
|
||||||
quality_issue_ref: str | None = None
|
quality_issue_ref: str | None = None
|
||||||
canonical_odometer = vehicle.odometer_km
|
if evaluation.odometer_regression:
|
||||||
if not odometer_regression:
|
|
||||||
canonical_odometer = body.end_odometer_km
|
|
||||||
vehicle.odometer_km = canonical_odometer
|
|
||||||
else:
|
|
||||||
issue = DataQualityIssue(
|
issue = DataQualityIssue(
|
||||||
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
||||||
rule_type="odometer_regression",
|
rule_type="odometer_regression",
|
||||||
@@ -121,7 +246,7 @@ def register_vehicle_return(
|
|||||||
evidence_json={
|
evidence_json={
|
||||||
"summary": (
|
"summary": (
|
||||||
f"Return submitted {body.end_odometer_km} km, below canonical "
|
f"Return submitted {body.end_odometer_km} km, below canonical "
|
||||||
f"{vehicle.odometer_km} km."
|
f"{evaluation.canonical_odometer_km} km."
|
||||||
),
|
),
|
||||||
"entity_ref": vehicle.public_ref,
|
"entity_ref": vehicle.public_ref,
|
||||||
"related_refs": [booking.public_ref, inspection.public_ref],
|
"related_refs": [booking.public_ref, inspection.public_ref],
|
||||||
@@ -133,7 +258,8 @@ def register_vehicle_return(
|
|||||||
db.flush()
|
db.flush()
|
||||||
quality_issue_ref = issue.public_ref
|
quality_issue_ref = issue.public_ref
|
||||||
|
|
||||||
resulting_status = _derive_vehicle_status(body, vehicle, canonical_odometer)
|
resulting_status = evaluation.resulting_vehicle_status
|
||||||
|
vehicle.odometer_km = evaluation.resulting_odometer_km
|
||||||
vehicle.operational_status = resulting_status
|
vehicle.operational_status = resulting_status
|
||||||
vehicle.version += 1
|
vehicle.version += 1
|
||||||
|
|
||||||
@@ -164,14 +290,6 @@ def register_vehicle_return(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
attention_reasons = []
|
|
||||||
if body.damage_reported:
|
|
||||||
attention_reasons.append("damage_reported")
|
|
||||||
if body.technical_warning:
|
|
||||||
attention_reasons.append("technical_warning")
|
|
||||||
if odometer_regression:
|
|
||||||
attention_reasons.append("odometer_regression")
|
|
||||||
|
|
||||||
event = OutboxEvent(
|
event = OutboxEvent(
|
||||||
event_id=uuid.uuid4(),
|
event_id=uuid.uuid4(),
|
||||||
event_type="vehicle.returned.v1",
|
event_type="vehicle.returned.v1",
|
||||||
@@ -189,7 +307,7 @@ def register_vehicle_return(
|
|||||||
"vehicle_ref": vehicle.public_ref,
|
"vehicle_ref": vehicle.public_ref,
|
||||||
"inspection_ref": inspection.public_ref,
|
"inspection_ref": inspection.public_ref,
|
||||||
"resulting_vehicle_status": resulting_status,
|
"resulting_vehicle_status": resulting_status,
|
||||||
"attention_reasons": attention_reasons,
|
"attention_reasons": evaluation.attention_reasons,
|
||||||
},
|
},
|
||||||
"aggregate_ref": booking.public_ref,
|
"aggregate_ref": booking.public_ref,
|
||||||
},
|
},
|
||||||
@@ -199,33 +317,15 @@ def register_vehicle_return(
|
|||||||
)
|
)
|
||||||
db.add(event)
|
db.add(event)
|
||||||
|
|
||||||
next_booking = db.scalar(
|
|
||||||
select(Booking)
|
|
||||||
.where(
|
|
||||||
Booking.vehicle_id == vehicle.id,
|
|
||||||
Booking.status == "reserved",
|
|
||||||
Booking.starts_at > now,
|
|
||||||
)
|
|
||||||
.order_by(Booking.starts_at.asc())
|
|
||||||
)
|
|
||||||
next_booking_risk = None
|
|
||||||
if next_booking is not None:
|
|
||||||
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
|
|
||||||
next_booking_risk = {
|
|
||||||
"booking_ref": next_booking.public_ref,
|
|
||||||
"starts_at": next_booking.starts_at.isoformat(),
|
|
||||||
"at_risk": resulting_status != "cleaning" or hours_until < 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
response_body = {
|
response_body = {
|
||||||
"booking_ref": booking.public_ref,
|
"booking_ref": booking.public_ref,
|
||||||
"vehicle_ref": vehicle.public_ref,
|
"vehicle_ref": vehicle.public_ref,
|
||||||
"inspection_ref": inspection.public_ref,
|
"inspection_ref": inspection.public_ref,
|
||||||
"resulting_vehicle_status": resulting_status,
|
"resulting_vehicle_status": resulting_status,
|
||||||
"odometer_regression": odometer_regression,
|
"odometer_regression": evaluation.odometer_regression,
|
||||||
"quality_issue_ref": quality_issue_ref,
|
"quality_issue_ref": quality_issue_ref,
|
||||||
"workflow_event_id": str(event.event_id),
|
"workflow_event_id": str(event.event_id),
|
||||||
"next_booking_risk": next_booking_risk,
|
"next_booking_risk": evaluation.next_booking_risk,
|
||||||
}
|
}
|
||||||
|
|
||||||
db.add(
|
db.add(
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""The single authoritative vehicle-status evaluator.
|
||||||
|
|
||||||
|
Used by the data-quality scanner (detection), the status-recommendation preview
|
||||||
|
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
|
||||||
|
recommendation can never structurally disagree (see docs/fleet-ops-correction/
|
||||||
|
vehicle-status-decision-table.md for the full decision table and rationale).
|
||||||
|
|
||||||
|
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
|
||||||
|
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
|
||||||
|
never from a proxy like "does some other high-severity issue happen to be open". It is
|
||||||
|
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
|
||||||
|
the same vehicle never changes what this function returns, because it never looks at
|
||||||
|
issue history, only at the vehicle's/bookings' current state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
# Every code below is a stable, localizable identifier -- see
|
||||||
|
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
|
||||||
|
# human-language mapping in all three supported locales. The backend never emits prose.
|
||||||
|
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
|
||||||
|
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
|
||||||
|
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
|
||||||
|
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
|
||||||
|
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VehicleStatusFacts:
|
||||||
|
active_booking_refs: list[str] = field(default_factory=list)
|
||||||
|
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
service_threshold_reached: bool = False
|
||||||
|
odometer_km: int = 0
|
||||||
|
next_service_km: int = 0
|
||||||
|
open_booking_overlap_issue_ref: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_active_rental(self) -> bool:
|
||||||
|
return len(self.active_booking_refs) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_booking_conflict(self) -> bool:
|
||||||
|
return (
|
||||||
|
len(self.overlapping_booking_pairs) > 0
|
||||||
|
or self.open_booking_overlap_issue_ref is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"active_booking_refs": self.active_booking_refs,
|
||||||
|
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
|
||||||
|
"service_threshold_reached": self.service_threshold_reached,
|
||||||
|
"odometer_km": self.odometer_km,
|
||||||
|
"next_service_km": self.next_service_km,
|
||||||
|
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VehicleStatusRecommendation:
|
||||||
|
current_status: str
|
||||||
|
recommended_status: str | None
|
||||||
|
recommendation_code: str
|
||||||
|
safe_to_apply: bool
|
||||||
|
manual_review_required: bool
|
||||||
|
facts: VehicleStatusFacts
|
||||||
|
blocking_reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
|
||||||
|
ordered = sorted(bookings, key=lambda b: b.starts_at)
|
||||||
|
pairs: list[tuple[Booking, Booking]] = []
|
||||||
|
for i, first in enumerate(ordered):
|
||||||
|
for second in ordered[i + 1 :]:
|
||||||
|
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||||
|
pairs.append((first, second))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def gather_vehicle_status_facts(
|
||||||
|
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
|
||||||
|
) -> VehicleStatusFacts:
|
||||||
|
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
|
||||||
|
derived from another issue's mere existence (only a *specific* booking_overlap
|
||||||
|
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
|
||||||
|
reserved_or_active = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Booking).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status.in_(["reserved", "active"]),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
|
||||||
|
overlap_pairs = [
|
||||||
|
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
|
||||||
|
]
|
||||||
|
|
||||||
|
overlap_issue_query = select(DataQualityIssue.public_ref).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.rule_type == "booking_overlap",
|
||||||
|
)
|
||||||
|
if exclude_issue_id is not None:
|
||||||
|
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
|
||||||
|
open_overlap_ref = db.scalar(overlap_issue_query)
|
||||||
|
|
||||||
|
return VehicleStatusFacts(
|
||||||
|
active_booking_refs=active_refs,
|
||||||
|
overlapping_booking_pairs=overlap_pairs,
|
||||||
|
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
open_booking_overlap_issue_ref=open_overlap_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
|
||||||
|
"""A short digest of exactly the facts the recommendation was based on, plus the
|
||||||
|
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
|
||||||
|
facts and rejects the request if it doesn't match the token the client last saw --
|
||||||
|
the frontend must never assume a previously-shown preview is still valid without the
|
||||||
|
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
|
||||||
|
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
|
||||||
|
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
|
return digest[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_vehicle_status(
|
||||||
|
vehicle: Vehicle, facts: VehicleStatusFacts
|
||||||
|
) -> VehicleStatusRecommendation:
|
||||||
|
"""Pure decision logic over already-gathered facts -- see
|
||||||
|
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
|
||||||
|
never queries the database itself (call gather_vehicle_status_facts first), so it is
|
||||||
|
trivial to unit-test every branch in isolation."""
|
||||||
|
current = vehicle.operational_status
|
||||||
|
blocking_reasons: list[str] = []
|
||||||
|
if facts.service_threshold_reached:
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||||
|
if facts.has_booking_conflict:
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
|
||||||
|
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
|
||||||
|
# Already being in maintenance is itself a real blocking fact -- an active
|
||||||
|
# booking never overrides it. This is exactly the forbidden shortcut this
|
||||||
|
# evaluator must never take (maintenance + active booking -> auto "rented").
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||||
|
|
||||||
|
def result(
|
||||||
|
recommended: str | None, code: str, *, safe: bool, manual: bool
|
||||||
|
) -> VehicleStatusRecommendation:
|
||||||
|
return VehicleStatusRecommendation(
|
||||||
|
current_status=current,
|
||||||
|
recommended_status=recommended,
|
||||||
|
recommendation_code=code,
|
||||||
|
safe_to_apply=safe,
|
||||||
|
manual_review_required=manual,
|
||||||
|
facts=facts,
|
||||||
|
blocking_reasons=blocking_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_active_rental and not blocking_reasons:
|
||||||
|
if current == "rented":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result(
|
||||||
|
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_active_rental and blocking_reasons:
|
||||||
|
# Explicitly forbidden shortcut this evaluator must never take: an active
|
||||||
|
# booking is not proof the vehicle should be "rented" when a real blocking
|
||||||
|
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
|
||||||
|
# This is a real contradiction in the underlying facts, not something safe to
|
||||||
|
# resolve automatically.
|
||||||
|
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||||
|
|
||||||
|
if facts.service_threshold_reached:
|
||||||
|
if current == "maintenance":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result(
|
||||||
|
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_booking_conflict:
|
||||||
|
if current == "blocked":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result(
|
||||||
|
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# No active rental, no maintenance need, no booking conflict.
|
||||||
|
if current in ("available", "cleaning", "blocked"):
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
if current == "rented":
|
||||||
|
return result(
|
||||||
|
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
|
||||||
|
)
|
||||||
|
if current == "maintenance":
|
||||||
|
# No positive fact confirms maintenance is actually finished (no completed
|
||||||
|
# service record is tracked here) -- clearing "maintenance" without such a
|
||||||
|
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
|
||||||
|
# Releasing a vehicle from maintenance remains an explicit, manual decision.
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
|
||||||
|
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
def test_demo_login_is_audited(ops_client):
|
def test_demo_login_is_audited(ops_client):
|
||||||
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
|
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -9,3 +16,59 @@ def test_demo_login_is_audited(ops_client):
|
|||||||
def test_audit_requires_authentication(client):
|
def test_audit_requires_authentication(client):
|
||||||
response = client.get("/api/v1/audit")
|
response = client.get("/api/v1/audit")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.get("/api/v1/audit")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
booking = db.scalar(
|
||||||
|
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
|
||||||
|
)
|
||||||
|
booking.status = "active"
|
||||||
|
booking.start_odometer_km = start_odometer_km
|
||||||
|
booking.end_odometer_km = None
|
||||||
|
db.commit()
|
||||||
|
return booking.public_ref
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_registered_audit_event_exposes_before_after_and_link(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-015", start_odometer_km=17000)
|
||||||
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-015").json()
|
||||||
|
ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json={
|
||||||
|
"end_odometer_km": vehicle_before["odometer_km"] + 10,
|
||||||
|
"fuel_level_percent": 50,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": False,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
headers={"Idempotency-Key": "test-audit-before-after-001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
key = "test-audit-before-after-001"
|
||||||
|
events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "return_registered"}
|
||||||
|
).json()
|
||||||
|
event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
|
||||||
|
assert event["before"] == {"status": "active"}
|
||||||
|
assert event["after"]["status"] == "returned"
|
||||||
|
assert event["entity_ref"] == booking_ref
|
||||||
|
assert event["entity_link"] == f"/bookings/{booking_ref}"
|
||||||
|
|
||||||
|
vehicle_events = ops_client.get(
|
||||||
|
"/api/v1/audit",
|
||||||
|
params={"action": "vehicle_status_changed", "correlation_id": event["correlation_id"]},
|
||||||
|
).json()
|
||||||
|
assert len(vehicle_events) == 1
|
||||||
|
assert vehicle_events[0]["entity_ref"] == "MO-015"
|
||||||
|
assert vehicle_events[0]["entity_link"] == "/vehicles/MO-015"
|
||||||
|
assert vehicle_events[0]["before"]["odometer_km"] == vehicle_before["odometer_km"]
|
||||||
|
|||||||
@@ -17,4 +17,51 @@ def test_rental_employee_cannot_reset_demo(employee_client):
|
|||||||
def test_operations_manager_can_reset_demo(ops_client):
|
def test_operations_manager_can_reset_demo(ops_client):
|
||||||
response = ops_client.post("/api/v1/demo/reset")
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["counts"]["vehicles"] == 50
|
body = response.json()
|
||||||
|
assert body["counts"]["vehicles"] == 50
|
||||||
|
assert body["anchor_date"]
|
||||||
|
assert body["seeded_at"]
|
||||||
|
assert body["scenario_integrity"]["all_ready"] is True
|
||||||
|
assert body["scenario_integrity"]["not_ready"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||||
|
import app.api.routers.demo as demo_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False)
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# Restore real demo data: this test intentionally disabled reset, so a following test
|
||||||
|
# module must not inherit a database left mid-mutation by an earlier test.
|
||||||
|
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True)
|
||||||
|
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_endpoint_requires_authentication(client):
|
||||||
|
response = client.get("/api/v1/demo/session")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_endpoint_confirms_logged_in_user(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["role"] == "operations_manager"
|
||||||
|
assert body["public_ref"] == "USR-OPS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_invalidates_session(ops_client):
|
||||||
|
confirmed = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert confirmed.status_code == 200
|
||||||
|
|
||||||
|
logout = ops_client.post("/api/v1/demo/logout")
|
||||||
|
assert logout.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert after.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_without_a_session_is_safe(client):
|
||||||
|
response = client.post("/api/v1/demo/logout")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
booking = db.scalar(
|
||||||
|
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
|
||||||
|
)
|
||||||
|
booking.status = "active"
|
||||||
|
booking.start_odometer_km = start_odometer_km
|
||||||
|
booking.end_odometer_km = None
|
||||||
|
db.commit()
|
||||||
|
return booking.public_ref
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def test_list_includes_all_five_rule_types(ops_client):
|
def test_list_includes_all_five_rule_types(ops_client):
|
||||||
response = ops_client.get("/api/v1/data-quality/issues")
|
response = ops_client.get("/api/v1/data-quality/issues")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -25,6 +48,26 @@ def test_scan_requires_operations_manager(employee_client):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_issues_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.get("/api/v1/data-quality/issues")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_issue_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_defer_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/defer")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/reject")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_s2_duplicate_customer_issue_detail(ops_client):
|
def test_s2_duplicate_customer_issue_detail(ops_client):
|
||||||
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
response = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -104,3 +147,377 @@ def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
|
|||||||
json={"survivor_ref": "CUS-0012"},
|
json={"survivor_ref": "CUS-0012"},
|
||||||
)
|
)
|
||||||
assert replay.status_code == 409
|
assert replay.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlap_related_snapshots_are_typed_as_bookings_not_vehicles(ops_client):
|
||||||
|
body = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP").json()
|
||||||
|
assert len(body["related_snapshots"]) == 2
|
||||||
|
for snap in body["related_snapshots"]:
|
||||||
|
assert snap["entity_type"] == "booking"
|
||||||
|
assert snap["public_ref"] in {"BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B"}
|
||||||
|
assert "starts_at" in snap and "ends_at" in snap
|
||||||
|
|
||||||
|
|
||||||
|
def _first_open(ops_client, rule_type: str) -> dict:
|
||||||
|
issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"}
|
||||||
|
).json()
|
||||||
|
assert issues, f"expected at least one open {rule_type} issue"
|
||||||
|
return issues[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provide_fields_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-ATTENTION/provide-fields",
|
||||||
|
json={"fields": {"registration_number": "TST-001"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_provide_fields_rejects_wrong_rule_type(ops_client):
|
||||||
|
response = ops_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/provide-fields",
|
||||||
|
json={"fields": {"make": "Test"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["error"]["code"] == "NOT_A_MISSING_FIELD_ISSUE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provide_fields_rejects_disallowed_field(ops_client):
|
||||||
|
target = _first_open(ops_client, "missing_required_field")
|
||||||
|
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||||
|
disallowed = "city" if detail["entity_type"] == "customer" else "next_service_km"
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields",
|
||||||
|
json={"fields": {disallowed: "anything"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["error"]["code"] == "INVALID_FIELD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
|
||||||
|
issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"rule_type": "missing_required_field", "status": "open"},
|
||||||
|
).json()
|
||||||
|
target = next(i for i in issues if i["entity_type"] == "vehicle")
|
||||||
|
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/provide-fields",
|
||||||
|
json={
|
||||||
|
"fields": {
|
||||||
|
"registration_number": "TST-999",
|
||||||
|
"make": "TestMake",
|
||||||
|
"model": "TestModel",
|
||||||
|
"location": "Depot",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
assert vehicle["registration_number"] == "TST-999"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_overlap_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||||
|
json={"booking_ref": "BK-DEMO-OVERLAP-A"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_overlap_rejects_unrelated_booking(ops_client):
|
||||||
|
response = ops_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||||
|
json={"booking_ref": "BK-DEMO-RETURN"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["error"]["code"] == "INVALID_BOOKING_REFERENCE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
|
||||||
|
response = ops_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||||
|
json={"booking_ref": "BK-DEMO-OVERLAP-A", "note": "Blocked the later commitment."},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
booking = ops_client.get("/api/v1/bookings/BK-DEMO-OVERLAP-A").json()
|
||||||
|
assert booking["status"] == "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_recommendation_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/status-recommendation"
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recommended_status_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status",
|
||||||
|
json={"recommendation_token": "irrelevant"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
|
||||||
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
|
||||||
|
preview_response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
|
||||||
|
)
|
||||||
|
assert preview_response.status_code == 200
|
||||||
|
preview = preview_response.json()
|
||||||
|
assert preview["current_status"] == vehicle_before["operational_status"]
|
||||||
|
assert preview["recommendation_token"]
|
||||||
|
assert "facts" in preview
|
||||||
|
|
||||||
|
# Calling preview again (as the UI would on every open) must still not mutate.
|
||||||
|
ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation")
|
||||||
|
issue_after = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||||
|
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
assert issue_after["status"] == "open"
|
||||||
|
assert vehicle_after["operational_status"] == vehicle_before["operational_status"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recommended_status_resolves_conflict(ops_client):
|
||||||
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
|
||||||
|
).json()
|
||||||
|
assert preview["safe_to_apply"] is True
|
||||||
|
assert preview["manual_review_required"] is False
|
||||||
|
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": preview["recommendation_token"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["issue"]["status"] == "resolved"
|
||||||
|
assert body["applied_status"] == preview["recommended_status"]
|
||||||
|
assert body["reason_code"] == preview["recommendation_code"]
|
||||||
|
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
assert vehicle["operational_status"] == body["applied_status"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recommended_status_rejects_stale_token(ops_client):
|
||||||
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": "not-a-real-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["error"]["code"] == "RECOMMENDATION_STALE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
||||||
|
json={"decision": "retain_canonical"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_odometer_regression_correction_below_canonical_is_rejected_then_retained(
|
||||||
|
ops_client,
|
||||||
|
):
|
||||||
|
target = _first_open(ops_client, "odometer_regression")
|
||||||
|
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
|
||||||
|
too_low = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/resolve-odometer-regression",
|
||||||
|
json={
|
||||||
|
"decision": "correct_reading",
|
||||||
|
"booking_ref": "BK-DEMO-RETURN",
|
||||||
|
"corrected_odometer_km": max(vehicle_before["odometer_km"] - 100, 0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert too_low.status_code == 422
|
||||||
|
assert too_low.json()["error"]["code"] in (
|
||||||
|
"CORRECTION_BELOW_CANONICAL",
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The rejected attempt must not have resolved or mutated anything.
|
||||||
|
still_open = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||||
|
assert still_open["status"] == "open"
|
||||||
|
|
||||||
|
retained = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/resolve-odometer-regression",
|
||||||
|
json={"decision": "retain_canonical", "note": "Submitted reading treated as erroneous."},
|
||||||
|
)
|
||||||
|
assert retained.status_code == 200
|
||||||
|
assert retained.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_client):
|
||||||
|
# The seeded odometer_regression issues carry no related booking (CSV-only rows).
|
||||||
|
# Create a fresh one with a real related booking via a live regression return, so
|
||||||
|
# the "correct_reading" path has an actual booking_ref to target.
|
||||||
|
booking_ref = _activate_booking("MO-018", start_odometer_km=12000)
|
||||||
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-018").json()
|
||||||
|
low_reading = vehicle_before["odometer_km"] - 200
|
||||||
|
returned = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json={
|
||||||
|
"end_odometer_km": low_reading,
|
||||||
|
"fuel_level_percent": 50,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": False,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
headers={"Idempotency-Key": "test-dq-odometer-correct-001"},
|
||||||
|
)
|
||||||
|
assert returned.status_code == 201
|
||||||
|
issue_ref = returned.json()["quality_issue_ref"]
|
||||||
|
assert issue_ref is not None
|
||||||
|
|
||||||
|
corrected = vehicle_before["odometer_km"] + 500
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression",
|
||||||
|
json={
|
||||||
|
"decision": "correct_reading",
|
||||||
|
"booking_ref": booking_ref,
|
||||||
|
"corrected_odometer_km": corrected,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
vehicle = ops_client.get("/api/v1/vehicles/MO-018").json()
|
||||||
|
assert vehicle["odometer_km"] == corrected
|
||||||
|
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
||||||
|
assert booking["end_odometer_km"] == corrected
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_scan_records_audit_event(ops_client):
|
||||||
|
scan = ops_client.post("/api/v1/data-quality/scan")
|
||||||
|
assert scan.status_code == 200
|
||||||
|
|
||||||
|
events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
||||||
|
).json()
|
||||||
|
assert len(events) >= 1
|
||||||
|
assert "created" in events[0]["metadata"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_demo(ops_client) -> None:
|
||||||
|
# /api/v1/demo/reset deletes the session cookie (the reset recreates the users
|
||||||
|
# table, so the old session's user id no longer exists) -- the caller must log back
|
||||||
|
# in before making any further authenticated call with the same client.
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
login_response = ops_client.post(
|
||||||
|
"/api/v1/demo/login", json={"role": "operations_manager"}
|
||||||
|
)
|
||||||
|
assert login_response.status_code == 200, login_response.text
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_overlap_issue(ops_client, *, booking_to_block: str) -> None:
|
||||||
|
overlap = _first_open(ops_client, "booking_overlap")
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{overlap['public_ref']}/resolve-overlap",
|
||||||
|
json={"booking_ref": booking_to_block},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
def _first_open_for_vehicle(ops_client, rule_type: str, vehicle_ref: str) -> dict:
|
||||||
|
issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"}
|
||||||
|
).json()
|
||||||
|
match = next((i for i in issues if i["entity_ref"] == vehicle_ref), None)
|
||||||
|
assert match, f"expected an open {rule_type} issue for {vehicle_ref}"
|
||||||
|
return match
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status_recommendation(ops_client, public_ref: str) -> dict:
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{public_ref}/status-recommendation"
|
||||||
|
).json()
|
||||||
|
apply_response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{public_ref}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": preview["recommendation_token"]},
|
||||||
|
)
|
||||||
|
assert apply_response.status_code == 200, apply_response.text
|
||||||
|
return apply_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mo_016_status_conflict_recommendation_is_order_independent(ops_client):
|
||||||
|
# MO-016 carries both a booking_overlap (DQ-DEMO-OVERLAP) and a vehicle_status_conflict
|
||||||
|
# (DQ-DEMO-STATUS) issue at once. Order independence does NOT mean "the same final
|
||||||
|
# vehicle status regardless of order" -- resolving the overlap first genuinely removes
|
||||||
|
# the conflict, so there is correctly nothing left to apply. What must hold in either
|
||||||
|
# order: the recommendation always reflects the real, current facts (never a stale
|
||||||
|
# "was some other issue open" proxy), and nothing unsafe is ever applied (never
|
||||||
|
# "rented", never a status change once the underlying condition has already resolved
|
||||||
|
# itself). See docs/fleet-ops-correction/current-gap-audit.md §6-7 and
|
||||||
|
# vehicle-status-decision-table.md.
|
||||||
|
|
||||||
|
# Order A: resolve the booking overlap first. The status-conflict issue's own
|
||||||
|
# recommendation must now correctly report that the conflict is gone -- nothing unsafe
|
||||||
|
# should be auto-applied, and the vehicle (never touched) stays exactly as it was.
|
||||||
|
_reset_demo(ops_client)
|
||||||
|
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
|
||||||
|
status_issue_a = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016")
|
||||||
|
preview_a = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{status_issue_a['public_ref']}/status-recommendation"
|
||||||
|
).json()
|
||||||
|
assert preview_a["recommendation_code"] == "vehicle.no_conflict"
|
||||||
|
assert preview_a["recommended_status"] is None
|
||||||
|
assert preview_a["safe_to_apply"] is False
|
||||||
|
vehicle_a = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||||
|
assert vehicle_a["operational_status"] == "available"
|
||||||
|
|
||||||
|
# Order B: resolve the status conflict first, while the overlap is still open -- the
|
||||||
|
# conflict genuinely still exists, so the evaluator must still detect it and safely
|
||||||
|
# resolve it (never "rented").
|
||||||
|
_reset_demo(ops_client)
|
||||||
|
status_issue_b = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016")
|
||||||
|
result_b = _apply_status_recommendation(ops_client, status_issue_b["public_ref"])
|
||||||
|
assert result_b["applied_status"] != "rented"
|
||||||
|
vehicle_b_mid = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||||
|
assert vehicle_b_mid["operational_status"] == result_b["applied_status"]
|
||||||
|
|
||||||
|
# Resolving the now-redundant overlap afterwards must not itself change the vehicle's
|
||||||
|
# status as a side effect.
|
||||||
|
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
|
||||||
|
vehicle_b = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||||
|
assert vehicle_b["operational_status"] == result_b["applied_status"]
|
||||||
|
assert vehicle_b["operational_status"] != "rented"
|
||||||
|
|
||||||
|
_reset_demo(ops_client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
|
||||||
|
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
|
||||||
|
# next scan re-detects the same unresolved condition -- it must not silently vanish
|
||||||
|
# or reopen the old row, but the new issue should stay linked to the rejection.
|
||||||
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
rejected = ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/reject")
|
||||||
|
assert rejected.status_code == 200
|
||||||
|
|
||||||
|
rescan = ops_client.post("/api/v1/data-quality/scan")
|
||||||
|
assert rescan.status_code == 200
|
||||||
|
assert rescan.json()["created"].get("vehicle_status_conflict", 0) >= 1
|
||||||
|
|
||||||
|
reopened = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"rule_type": "vehicle_status_conflict", "status": "open"},
|
||||||
|
).json()
|
||||||
|
match = next(
|
||||||
|
(i for i in reopened if i["evidence"].get("reopened_from") == target["public_ref"]), None
|
||||||
|
)
|
||||||
|
assert match is not None, "expected a new issue linked back to the rejected one"
|
||||||
|
assert match["evidence"]["previous_decision"] == "rejected"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_manifest_is_public(client):
|
||||||
|
# No login call at all -- the demo-entry screen and badge need this before any
|
||||||
|
# session exists.
|
||||||
|
response = client.get("/api/v1/demo/manifest")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_manifest_shape(client):
|
||||||
|
body = client.get("/api/v1/demo/manifest").json()
|
||||||
|
assert body["organization_name"] == "Northstar Mobility"
|
||||||
|
assert body["demo_mode"] is True
|
||||||
|
assert body["synthetic_data"] is True
|
||||||
|
assert body["allow_reset"] is True
|
||||||
|
assert body["timezone"] == "Europe/Brussels"
|
||||||
|
assert body["guide_available"] is True
|
||||||
|
assert set(body["required_roles"]) == {"operations_manager", "rental_employee"}
|
||||||
|
assert body["last_reset_at"] is not None
|
||||||
|
assert body["anchor_date"] is not None
|
||||||
|
|
||||||
|
scenario_ids = {s["id"] for s in body["scenarios"]}
|
||||||
|
assert scenario_ids == {
|
||||||
|
"return-anomaly",
|
||||||
|
"duplicate-customer",
|
||||||
|
"booking-overlap",
|
||||||
|
"automation-retry",
|
||||||
|
"knowledge-question",
|
||||||
|
}
|
||||||
|
integration_keys = {i["key"] for i in body["integrations"]}
|
||||||
|
assert integration_keys == {"n8n", "ragcore", "mcp_hub"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_manifest_scenarios_ready_after_fresh_reset(client):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
body = client.get("/api/v1/demo/manifest").json()
|
||||||
|
scenarios = {s["id"]: s for s in body["scenarios"]}
|
||||||
|
for scenario_id, scenario in scenarios.items():
|
||||||
|
assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset"
|
||||||
|
assert scenario["blocked_reason_code"] is None
|
||||||
|
assert scenario["start_path"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
|
||||||
|
body = client.get("/api/v1/demo/manifest").json()
|
||||||
|
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
|
||||||
|
assert ragcore["status_code"] == "demoMode"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -67,7 +67,7 @@ def test_deliver_one_success(monkeypatch):
|
|||||||
event_id = _make_pending_event("MO-002")
|
event_id = _make_pending_event("MO-002")
|
||||||
dispatcher._claim_due_events()
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
@@ -81,13 +81,14 @@ def test_deliver_one_success(monkeypatch):
|
|||||||
assert event.attempts == 1
|
assert event.attempts == 1
|
||||||
assert event.external_run_id == str(event_id)
|
assert event.external_run_id == str(event_id)
|
||||||
assert event.last_error is None
|
assert event.last_error is None
|
||||||
|
assert event.last_error_code is None
|
||||||
|
|
||||||
|
|
||||||
def test_deliver_one_failure_schedules_retry(monkeypatch):
|
def test_deliver_one_failure_schedules_retry(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-003")
|
event_id = _make_pending_event("MO-003")
|
||||||
dispatcher._claim_due_events()
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
raise dispatcher.httpx.ConnectError("simulated connection failure")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -98,13 +99,41 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
|
|||||||
assert event.attempts == 1
|
assert event.attempts == 1
|
||||||
assert event.next_attempt_at is not None
|
assert event.next_attempt_at is not None
|
||||||
assert "simulated connection failure" in event.last_error
|
assert "simulated connection failure" in event.last_error
|
||||||
|
assert event.last_error_code == "connectionError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
|
||||||
|
# Reproduces a real failure mode found while live-validating the n8n webhook auth
|
||||||
|
# fix: a workflow that errors internally before its "Respond to Webhook" node runs
|
||||||
|
# can still answer with a 2xx status and an empty body. response.json() on that body
|
||||||
|
# raises json.JSONDecodeError -- this must be treated as a retryable failure, not an
|
||||||
|
# unhandled exception that leaves the event stuck in "delivering" forever.
|
||||||
|
event_id = _make_pending_event("MO-005")
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
|
def fake_post(url, json, headers, timeout):
|
||||||
|
def raise_json_error():
|
||||||
|
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None, json=raise_json_error, status_code=200
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
|
dispatcher._deliver_one(event_id)
|
||||||
|
|
||||||
|
event = _get_event(event_id)
|
||||||
|
assert event.delivery_status == "pending"
|
||||||
|
assert event.attempts == 1
|
||||||
|
assert event.next_attempt_at is not None
|
||||||
|
assert event.last_error_code == "malformedResponse"
|
||||||
|
|
||||||
|
|
||||||
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-004")
|
event_id = _make_pending_event("MO-004")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise dispatcher.httpx.ConnectError("still down")
|
raise dispatcher.httpx.ConnectError("still down")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -147,7 +176,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
def fake_post(url, json, headers, timeout):
|
||||||
raise AssertionError("must not attempt delivery with a malformed payload")
|
raise AssertionError("must not attempt delivery with a malformed payload")
|
||||||
|
|
||||||
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
@@ -159,12 +188,91 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
|||||||
assert event.delivery_status in ("pending", "failed")
|
assert event.delivery_status in ("pending", "failed")
|
||||||
assert event.attempts == 1
|
assert event.attempts == 1
|
||||||
assert "Malformed outbox payload" in event.last_error
|
assert "Malformed outbox payload" in event.last_error
|
||||||
|
assert event.last_error_code == "malformedPayload"
|
||||||
|
|
||||||
|
|
||||||
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
def test_claim_sets_a_lease_deadline():
|
||||||
event_id = _make_pending_event("MO-005")
|
event_id = _make_pending_event("MO-006")
|
||||||
|
settings = get_settings()
|
||||||
|
before = datetime.now(UTC)
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
def fake_post(url, json, timeout):
|
event = _get_event(event_id)
|
||||||
|
assert event.delivery_status == "delivering"
|
||||||
|
assert event.next_attempt_at is not None
|
||||||
|
lease = settings.n8n_delivery_lease_seconds
|
||||||
|
assert event.next_attempt_at > before + timedelta(seconds=lease - 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reclaim_ignores_an_active_unexpired_lease():
|
||||||
|
# A worker that is still within its lease window must not be disturbed -- this is
|
||||||
|
# what prevents double delivery of an event another (still-alive) worker is handling.
|
||||||
|
event_id = _make_pending_event("MO-007")
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
|
reclaimed = dispatcher._reclaim_stale_deliveries()
|
||||||
|
assert reclaimed == 0
|
||||||
|
assert _get_event(event_id).delivery_status == "delivering"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
|
||||||
|
# Simulates a process crash: the row was claimed (delivering) but no outcome was ever
|
||||||
|
# recorded, and its lease has since expired.
|
||||||
|
event_id = _make_pending_event("MO-008")
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||||
|
event.attempts = 2
|
||||||
|
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
reclaimed = dispatcher._reclaim_stale_deliveries()
|
||||||
|
assert reclaimed == 1
|
||||||
|
|
||||||
|
event = _get_event(event_id)
|
||||||
|
assert event.delivery_status == "pending"
|
||||||
|
assert event.next_attempt_at is None
|
||||||
|
assert event.attempts == 2
|
||||||
|
assert "stale" in event.last_error.lower()
|
||||||
|
assert event.last_error_code == "staleLeaseRecovered"
|
||||||
|
|
||||||
|
# The reclaimed event is now a normal pending event, immediately claimable again.
|
||||||
|
claimed = dispatcher._claim_due_events()
|
||||||
|
assert event_id in claimed
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
||||||
|
event_id = _make_pending_event("MO-009")
|
||||||
|
dispatcher._claim_due_events()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
|
||||||
|
event.next_attempt_at = datetime.now(UTC) - timedelta(seconds=1)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def fake_post(url, json, headers, timeout):
|
||||||
|
return SimpleNamespace(
|
||||||
|
raise_for_status=lambda: None,
|
||||||
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
|
||||||
|
processed = dispatcher.run_dispatch_cycle()
|
||||||
|
|
||||||
|
assert processed >= 1
|
||||||
|
assert _get_event(event_id).delivery_status == "succeeded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||||
|
event_id = _make_pending_event("MO-005")
|
||||||
|
|
||||||
|
def fake_post(url, json, headers, timeout):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
raise_for_status=lambda: None,
|
raise_for_status=lambda: None,
|
||||||
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
def test_integration_status_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.get("/api/v1/integrations/status")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_requires_authentication(client):
|
||||||
|
response = client.get("/api/v1/integrations/status")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/integrations/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
n8n = body["n8n"]
|
||||||
|
assert n8n["dispatch_enabled"] is True
|
||||||
|
assert n8n["succeeded"] >= 1
|
||||||
|
assert n8n["failed"] >= 1
|
||||||
|
# The seed deliberately carries both failed and succeeded events, so a single most-
|
||||||
|
# recent-event read would misreport health -- the aggregate must call this "degraded",
|
||||||
|
# not "operational" or "unavailable".
|
||||||
|
assert n8n["state"] == "degraded"
|
||||||
|
assert n8n["latest_success_at"] is not None
|
||||||
|
assert n8n["latest_failure_at"] is not None
|
||||||
|
|
||||||
|
mcp_hub = body["mcp_hub"]
|
||||||
|
assert mcp_hub["registration_enabled"] is False
|
||||||
|
assert mcp_hub["state"] == "not_configured"
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
|
||||||
|
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||||
|
for run in failed:
|
||||||
|
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
|
||||||
|
assert retried.status_code == 200
|
||||||
|
|
||||||
|
response = ops_client.get("/api/v1/integrations/status")
|
||||||
|
body = response.json()["n8n"]
|
||||||
|
assert body["failed"] == 0
|
||||||
|
assert body["state"] == "operational"
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
|
||||||
|
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert body["expected_workflow_count"] == 4
|
||||||
|
names = {w["name"] for w in body["workflows"]}
|
||||||
|
assert names == {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
}
|
||||||
|
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
|
||||||
|
assert ragcore_sync["built"] is False
|
||||||
|
assert ragcore_sync["last_seen_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
scan_workflow = next(
|
||||||
|
w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||||
|
)
|
||||||
|
assert scan_workflow["last_seen_at"] is None
|
||||||
|
|
||||||
|
scan = client.post(
|
||||||
|
"/api/v1/integrations/n8n/scheduled-scan",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert scan.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
scan_workflow = next(
|
||||||
|
w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
|
||||||
|
)
|
||||||
|
assert scan_workflow["last_seen_at"] is not None
|
||||||
|
assert after["known_workflow_count"] > before["known_workflow_count"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_status_reflects_error_handler_registrations(client, ops_client):
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
report = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json={
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Simulated failure for status test",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"attempt": 1,
|
||||||
|
},
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert report.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||||
|
assert (
|
||||||
|
after["error_handler"]["total_failures_registered"]
|
||||||
|
== before["error_handler"]["total_failures_registered"] + 1
|
||||||
|
)
|
||||||
|
assert after["error_handler"]["latest_failure_workflow"] == (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration"
|
||||||
|
)
|
||||||
|
handler_workflow = next(
|
||||||
|
w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler")
|
||||||
|
)
|
||||||
|
assert handler_workflow["last_seen_at"] is not None
|
||||||
@@ -82,3 +82,178 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
|
|||||||
).json()
|
).json()
|
||||||
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
|
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
|
||||||
assert len(matching) == 1
|
assert len(matching) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_scan_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/scheduled-scan",
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_scan_requires_service_token_header(client):
|
||||||
|
response = client.post("/api/v1/integrations/n8n/scheduled-scan")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_scan_runs_and_returns_counts_by_rule(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/scheduled-scan",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"created": {}} # already-seeded conditions, nothing new
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
||||||
|
).json()
|
||||||
|
service_triggered = [e for e in audit_events if e["actor_type"] == "service"]
|
||||||
|
assert len(service_triggered) >= 1
|
||||||
|
assert service_triggered[0]["actor_label"] == "n8n scheduled scan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
first = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
||||||
|
second = client.post("/api/v1/integrations/n8n/scheduled-scan", headers=headers)
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["created"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_error_body(execution_id: str, **overrides):
|
||||||
|
body = {
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Callback request failed with status 500",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"correlation_id": None,
|
||||||
|
"attempt": 1,
|
||||||
|
"retry_action": "n8n will retry automatically",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4())),
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_unknown_category(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = _workflow_error_body(execution_id)
|
||||||
|
|
||||||
|
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"]["error_category"] == "httpError"
|
||||||
|
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_bounds_summary_length(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_rejects_wrong_service_token(client):
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_lists_every_language_with_stable_ids(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
documents = response.json()["documents"]
|
||||||
|
assert len(documents) > 0
|
||||||
|
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
|
||||||
|
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
|
||||||
|
assert len(checkout_docs) == 3 # one per language
|
||||||
|
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
|
||||||
|
# Same document_id, different language, must not collide on id.
|
||||||
|
assert len({d["id"] for d in checkout_docs}) == 3
|
||||||
|
|
||||||
|
second_response = client.get(
|
||||||
|
"/api/v1/integrations/n8n/procedures",
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
second_ids = {d["id"] for d in second_response.json()["documents"]}
|
||||||
|
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_sync_result_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||||
|
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"] == {"synced": 33, "failed": 1}
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.services.knowledge.demo import DemoKnowledgeProvider
|
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||||
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
|
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
|
||||||
|
|
||||||
|
|
||||||
|
def test_brief_exact_damage_question_in_all_three_languages():
|
||||||
|
# The exact validation questions from docs/fleet-ops-correction/current-gap-audit.md
|
||||||
|
# -- each must ground on the damage procedure as its *primary* (top-ranked) source,
|
||||||
|
# not merely appear somewhere in the top-3, and the source/version/section/excerpt
|
||||||
|
# must all come from that same-language document (never an English fallback).
|
||||||
|
provider = DemoKnowledgeProvider()
|
||||||
|
cases = {
|
||||||
|
"nl-BE": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||||
|
"en-GB": "What should I do when a vehicle returns with damage?",
|
||||||
|
"fr-BE": "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
|
||||||
|
}
|
||||||
|
for language, question in cases.items():
|
||||||
|
answer = provider.ask(question, f"test-brief-{language}", language)
|
||||||
|
assert answer.evidence_state == "grounded", language
|
||||||
|
assert answer.sources, language
|
||||||
|
assert answer.sources[0].document_id == "damage-procedure", (
|
||||||
|
f"{language}: expected the damage procedure as the primary source, "
|
||||||
|
f"got {answer.sources[0].document_id!r}"
|
||||||
|
)
|
||||||
|
assert answer.answer
|
||||||
|
assert answer.sources[0].excerpt
|
||||||
|
|
||||||
|
|
||||||
|
def test_knowledge_procedures_never_mention_mobilityops_or_poc():
|
||||||
|
# Section 2 of docs/fleet-ops-correction/current-gap-audit.md: the visible brand
|
||||||
|
# name is exactly "Fleet Ops", and "PoC" must never appear in visible content --
|
||||||
|
# including the demo knowledge base, not just the frontend.
|
||||||
|
procedures_dir = Path(get_settings().knowledge_dir)
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(procedures_dir.glob("*/*.md")):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if "MobilityOps" in text or re.search(r"\bPoC\b", text):
|
||||||
|
offenders.append(str(path))
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
|
||||||
def test_s6_damage_question_is_grounded_with_expected_sources():
|
def test_s6_damage_question_is_grounded_with_expected_sources():
|
||||||
provider = DemoKnowledgeProvider()
|
provider = DemoKnowledgeProvider()
|
||||||
answer = provider.ask(
|
answer = provider.ask(
|
||||||
@@ -32,7 +72,52 @@ def test_demo_provider_health_reports_document_count():
|
|||||||
health = provider.health()
|
health = provider.health()
|
||||||
assert health.provider == "demo"
|
assert health.provider == "demo"
|
||||||
assert health.available is True
|
assert health.available is True
|
||||||
assert health.document_count == 10
|
assert health.document_count == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_provider_health_reports_document_count_per_language():
|
||||||
|
provider = DemoKnowledgeProvider()
|
||||||
|
for language in ("nl-BE", "en-GB", "fr-BE"):
|
||||||
|
assert provider.health(language).document_count == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_provider_grounds_damage_question_in_dutch():
|
||||||
|
provider = DemoKnowledgeProvider()
|
||||||
|
answer = provider.ask(
|
||||||
|
"Wat moet ik doen als een voertuig terugkomt met schade?",
|
||||||
|
"test-correlation-nl",
|
||||||
|
"nl-BE",
|
||||||
|
)
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
document_ids = {s.document_id for s in answer.sources}
|
||||||
|
assert "damage-procedure" in document_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_provider_grounds_damage_question_in_french():
|
||||||
|
provider = DemoKnowledgeProvider()
|
||||||
|
answer = provider.ask(
|
||||||
|
"Que dois-je faire quand un véhicule revient avec des dommages ?",
|
||||||
|
"test-correlation-fr",
|
||||||
|
"fr-BE",
|
||||||
|
)
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
document_ids = {s.document_id for s in answer.sources}
|
||||||
|
assert "damage-procedure" in document_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_provider_insufficient_evidence_message_is_localized():
|
||||||
|
provider = DemoKnowledgeProvider()
|
||||||
|
nl_answer = provider.ask(
|
||||||
|
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
|
||||||
|
)
|
||||||
|
fr_answer = provider.ask(
|
||||||
|
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
|
||||||
|
)
|
||||||
|
assert nl_answer.evidence_state == "insufficient"
|
||||||
|
assert fr_answer.evidence_state == "insufficient"
|
||||||
|
assert nl_answer.answer != fr_answer.answer
|
||||||
|
assert "France" not in nl_answer.answer
|
||||||
|
assert "France" not in fr_answer.answer
|
||||||
|
|
||||||
|
|
||||||
def test_ask_question_endpoint_grounded(ops_client):
|
def test_ask_question_endpoint_grounded(ops_client):
|
||||||
@@ -73,12 +158,190 @@ def test_knowledge_status_endpoint(ops_client):
|
|||||||
assert response.json()["provider"] == "demo"
|
assert response.json()["provider"] == "demo"
|
||||||
|
|
||||||
|
|
||||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
class _FakeResponse:
|
||||||
def fake_client(*args, **kwargs):
|
def __init__(self, status_code: int, body: dict):
|
||||||
raise httpx.ConnectError("no ragcore in this environment")
|
self.status_code = status_code
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, get_response=None, post_response=None, raise_on=None):
|
||||||
|
self._get_response = get_response
|
||||||
|
self._post_response = post_response
|
||||||
|
self._raise_on = raise_on
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
if self._raise_on == "get":
|
||||||
|
raise httpx.ConnectError("no ragcore in this environment")
|
||||||
|
return self._get_response
|
||||||
|
|
||||||
|
def post(self, path, json=None):
|
||||||
|
if self._raise_on == "post":
|
||||||
|
raise httpx.ConnectError("no ragcore in this environment")
|
||||||
|
return self._post_response
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||||
provider = RAGcoreKnowledgeProvider()
|
provider = RAGcoreKnowledgeProvider()
|
||||||
monkeypatch.setattr(provider, "_client", fake_client)
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post"))
|
||||||
answer = provider.ask("Anything?", "test-correlation-3")
|
answer = provider.ask("Anything?", "test-correlation-3")
|
||||||
assert answer.evidence_state == "unavailable"
|
assert answer.evidence_state == "unavailable"
|
||||||
assert answer.sources == []
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "")
|
||||||
|
|
||||||
|
def fail_if_called():
|
||||||
|
raise AssertionError("should not call RAGcore without a configured space id")
|
||||||
|
|
||||||
|
monkeypatch.setattr(provider, "_client", fail_if_called)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-no-space")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_reports_ready_status(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})),
|
||||||
|
)
|
||||||
|
health = provider.health()
|
||||||
|
assert health.provider == "ragcore"
|
||||||
|
assert health.available is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})),
|
||||||
|
)
|
||||||
|
health = provider.health()
|
||||||
|
assert health.available is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get"))
|
||||||
|
health = provider.health()
|
||||||
|
assert health.available is False
|
||||||
|
assert "unavailable" in health.detail.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _answers_body(**overrides) -> dict:
|
||||||
|
body = {
|
||||||
|
"answer": "Report damage and route the vehicle to maintenance.",
|
||||||
|
"answerability": "answerable",
|
||||||
|
"citations": [
|
||||||
|
{
|
||||||
|
"id": "cite-1",
|
||||||
|
"document_id": "doc-1",
|
||||||
|
"document_version_id": "version-1",
|
||||||
|
"title": "Damage handling procedure",
|
||||||
|
"section": "Detection",
|
||||||
|
"excerpt": "Inspect the vehicle for visible damage.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
|
||||||
|
assert answer.evidence_state == "grounded"
|
||||||
|
assert answer.answer
|
||||||
|
assert len(answer.sources) == 1
|
||||||
|
source = answer.sources[0]
|
||||||
|
assert source.document_id == "doc-1"
|
||||||
|
assert source.title == "Damage handling procedure"
|
||||||
|
assert source.version == "version-1"
|
||||||
|
assert source.section == "Detection"
|
||||||
|
assert source.excerpt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_response=_FakeResponse(
|
||||||
|
200,
|
||||||
|
_answers_body(
|
||||||
|
answer="This should never be shown.",
|
||||||
|
answerability="not_answerable",
|
||||||
|
citations=[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Unrelated question?", "test-correlation-insufficient")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.answer == ""
|
||||||
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(
|
||||||
|
post_response=_FakeResponse(
|
||||||
|
200, _answers_body(answerability="answerable", citations=[])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
answer = provider.ask("What must I do about damage?", "test-correlation-no-citations")
|
||||||
|
assert answer.evidence_state == "insufficient"
|
||||||
|
assert answer.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-401")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
|
||||||
|
provider = RAGcoreKnowledgeProvider()
|
||||||
|
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
provider,
|
||||||
|
"_client",
|
||||||
|
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})),
|
||||||
|
)
|
||||||
|
answer = provider.ask("Anything?", "test-correlation-malformed")
|
||||||
|
assert answer.evidence_state == "unavailable"
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
from app.models.booking import Booking
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +44,148 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_next_service_km(vehicle_ref: str, threshold: int) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
vehicle.next_service_km = threshold
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_reserved_booking(vehicle_ref: str, *, hours_from_now: float) -> str:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
customer = db.scalar(select(Customer))
|
||||||
|
starts_at = datetime.now(UTC) + timedelta(hours=hours_from_now)
|
||||||
|
public_ref = f"BK-TEST-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
booking = Booking(
|
||||||
|
public_ref=public_ref,
|
||||||
|
customer_id=customer.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
starts_at=starts_at,
|
||||||
|
ends_at=starts_at + timedelta(days=2),
|
||||||
|
status="reserved",
|
||||||
|
requirements_complete=True,
|
||||||
|
)
|
||||||
|
db.add(booking)
|
||||||
|
db.commit()
|
||||||
|
return public_ref
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _counts() -> tuple[int, int]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
len(db.scalars(select(AuditEvent)).all()),
|
||||||
|
len(db.scalars(select(OutboxEvent)).all()),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_performs_no_writes_and_matches_commit(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-006", start_odometer_km=30000)
|
||||||
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-006").json()
|
||||||
|
new_reading = vehicle_before["odometer_km"] + 25
|
||||||
|
body = _return_body(end_odometer_km=new_reading)
|
||||||
|
|
||||||
|
audit_before, outbox_before = _counts()
|
||||||
|
preview = ops_client.post(f"/api/v1/bookings/{booking_ref}/return-preview", json=body)
|
||||||
|
assert preview.status_code == 200
|
||||||
|
preview_body = preview.json()
|
||||||
|
audit_after, outbox_after = _counts()
|
||||||
|
assert (audit_after, outbox_after) == (audit_before, outbox_before)
|
||||||
|
|
||||||
|
booking_mid = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
|
||||||
|
assert booking_mid["status"] == "active" # preview did not mutate the booking
|
||||||
|
vehicle_mid = ops_client.get("/api/v1/vehicles/MO-006").json()
|
||||||
|
assert vehicle_mid["odometer_km"] == vehicle_before["odometer_km"]
|
||||||
|
|
||||||
|
assert preview_body["odometer_regression"] is False
|
||||||
|
assert preview_body["resulting_odometer_km"] == new_reading
|
||||||
|
assert preview_body["canonical_odometer_km"] == vehicle_before["odometer_km"]
|
||||||
|
|
||||||
|
commit = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json=body,
|
||||||
|
headers={"Idempotency-Key": "test-preview-matches-commit-001"},
|
||||||
|
)
|
||||||
|
assert commit.status_code == 201
|
||||||
|
commit_body = commit.json()
|
||||||
|
assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
|
||||||
|
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
|
||||||
|
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_detects_odometer_regression(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-007", start_odometer_km=15000)
|
||||||
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-007").json()
|
||||||
|
low_reading = vehicle_before["odometer_km"] - 100
|
||||||
|
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||||
|
json=_return_body(end_odometer_km=low_reading),
|
||||||
|
)
|
||||||
|
assert preview.status_code == 200
|
||||||
|
body = preview.json()
|
||||||
|
assert body["odometer_regression"] is True
|
||||||
|
assert body["would_create_quality_issue"] is True
|
||||||
|
assert "odometer_regression" in body["attention_reasons"]
|
||||||
|
assert body["resulting_odometer_km"] == vehicle_before["odometer_km"]
|
||||||
|
|
||||||
|
vehicle_after = ops_client.get("/api/v1/vehicles/MO-007").json()
|
||||||
|
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_detects_service_due(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-009", start_odometer_km=18000)
|
||||||
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-009").json()
|
||||||
|
_set_next_service_km("MO-009", vehicle_before["odometer_km"] + 50)
|
||||||
|
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||||
|
json=_return_body(end_odometer_km=vehicle_before["odometer_km"] + 100),
|
||||||
|
)
|
||||||
|
assert preview.status_code == 200
|
||||||
|
body = preview.json()
|
||||||
|
assert body["resulting_vehicle_status"] == "maintenance"
|
||||||
|
assert "service threshold" in body["status_reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_detects_next_booking_risk(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-011", start_odometer_km=19000)
|
||||||
|
_add_reserved_booking("MO-011", hours_from_now=2)
|
||||||
|
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||||
|
json=_return_body(end_odometer_km=19500),
|
||||||
|
)
|
||||||
|
assert preview.status_code == 200
|
||||||
|
risk = preview.json()["next_booking_risk"]
|
||||||
|
assert risk is not None
|
||||||
|
assert risk["at_risk"] is True # less than 4 hours away
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_requires_active_booking(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-014", start_odometer_km=21000)
|
||||||
|
ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json=_return_body(end_odometer_km=21500),
|
||||||
|
headers={"Idempotency-Key": "test-preview-requires-active-001"},
|
||||||
|
)
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return-preview",
|
||||||
|
json=_return_body(end_odometer_km=22000),
|
||||||
|
)
|
||||||
|
assert preview.status_code == 409
|
||||||
|
assert preview.json()["error"]["code"] == "INVALID_BOOKING_STATE"
|
||||||
|
|
||||||
|
|
||||||
def test_register_return_success_updates_canonical_odometer(ops_client):
|
def test_register_return_success_updates_canonical_odometer(ops_client):
|
||||||
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
|
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
|
||||||
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
def test_search_requires_authentication(client):
|
||||||
|
response = client.get("/api/v1/search", params={"q": "MO-001"})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_finds_a_vehicle_by_reference(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/search", params={"q": "MO-001"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
match = next((r for r in body["results"] if r["type"] == "vehicle"), None)
|
||||||
|
assert match is not None
|
||||||
|
assert match["label"] == "MO-001"
|
||||||
|
assert match["link"] == "/vehicles/MO-001"
|
||||||
|
# The backend must never send localizable prose -- only a stable code plus raw
|
||||||
|
# data params, so the frontend can render it in the operator's selected language.
|
||||||
|
assert match["detail_code"] == "vehicleSummary"
|
||||||
|
assert set(match["detail_params"]) == {"make", "model", "location"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_finds_a_booking_by_reference(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/search", params={"q": "BK-DEMO-RETURN"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
match = next((r for r in response.json()["results"] if r["type"] == "booking"), None)
|
||||||
|
assert match is not None
|
||||||
|
assert match["link"] == "/bookings/BK-DEMO-RETURN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_finds_a_data_quality_issue_for_operations_manager(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
match = next((r for r in response.json()["results"] if r["type"] == "data_quality_issue"), None)
|
||||||
|
assert match is not None
|
||||||
|
assert match["link"] == "/data-quality/DQ-DEMO-OVERLAP"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_never_returns_data_quality_issues_for_rental_employee(employee_client):
|
||||||
|
response = employee_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert all(r["type"] != "data_quality_issue" for r in response.json()["results"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_section_result_visible_to_operations_manager(ops_client):
|
||||||
|
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||||
|
match = next(
|
||||||
|
(r for r in result["results"] if r["type"] == "section" and r["link"] == "/audit"), None
|
||||||
|
)
|
||||||
|
assert match is not None
|
||||||
|
# Section results must ship a stable id, not English prose -- the frontend looks up
|
||||||
|
# navigation:items.<id> and search:sections.<id>.detail in the selected locale.
|
||||||
|
assert match["label"] == "audit"
|
||||||
|
assert match["detail_code"] == "audit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_section_matches_dutch_and_french_terms(ops_client):
|
||||||
|
nl_result = ops_client.get("/api/v1/search", params={"q": "wagenpark"}).json()
|
||||||
|
assert any(r["type"] == "section" and r["link"] == "/vehicles" for r in nl_result["results"])
|
||||||
|
fr_result = ops_client.get("/api/v1/search", params={"q": "réservation"}).json()
|
||||||
|
assert any(r["type"] == "section" and r["link"] == "/bookings" for r in fr_result["results"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
||||||
|
result = employee_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||||
|
assert all(r["link"] != "/audit" for r in result["results"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_never_returns_customer_results(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/search", params={"q": "CUS-0012"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert all(r["type"] != "customer" for r in response.json()["results"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_no_match_returns_empty_results(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/search", params={"q": "zzz-no-such-thing-zzz"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["results"] == []
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
from app.models.booking import Booking
|
from app.models.booking import Booking
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.data_quality import DataQualityIssue
|
from app.models.data_quality import DataQualityIssue
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.seed_loader import reset_and_seed
|
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
def test_seed_counts_match_deterministic_dataset():
|
def test_seed_counts_match_deterministic_dataset():
|
||||||
@@ -20,8 +23,12 @@ def test_seed_counts_match_deterministic_dataset():
|
|||||||
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
||||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
||||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan.
|
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
||||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 26
|
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
|
||||||
|
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
||||||
|
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||||
|
# previous hand-rolled scanner never checked for.
|
||||||
|
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
|
||||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||||
finally:
|
finally:
|
||||||
@@ -52,3 +59,118 @@ def test_seed_demo_scenarios_present():
|
|||||||
assert failed_run is not None
|
assert failed_run is not None
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _by_ref(db, model, ref):
|
||||||
|
return db.scalar(select(model).where(model.public_ref == ref))
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_scenario_s1_odometer_regression_return():
|
||||||
|
"""S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a
|
||||||
|
below-canonical odometer reading, using the vehicle's own current odometer."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||||
|
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||||
|
assert booking is not None and vehicle is not None
|
||||||
|
assert booking.vehicle_id == vehicle.id
|
||||||
|
assert booking.status == "active"
|
||||||
|
assert booking.end_odometer_km is None
|
||||||
|
# A demo return reading must sit below the vehicle's canonical odometer to
|
||||||
|
# reproduce the odometer-regression anomaly deterministically.
|
||||||
|
assert vehicle.odometer_km > 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_scenario_s2_duplicate_customer_pair():
|
||||||
|
"""S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
primary = _by_ref(db, Customer, "CUS-0012")
|
||||||
|
duplicate = _by_ref(db, Customer, "CUS-0178")
|
||||||
|
assert primary is not None and duplicate is not None
|
||||||
|
assert primary.email == duplicate.email
|
||||||
|
assert duplicate.merged_into_customer_id is None
|
||||||
|
|
||||||
|
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE")
|
||||||
|
assert issue is not None
|
||||||
|
assert issue.rule_type == "possible_duplicate_customer"
|
||||||
|
assert issue.status == "open"
|
||||||
|
related = issue.evidence_json.get("related_refs", [])
|
||||||
|
assert "CUS-0012" in related or "CUS-0178" in related
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_scenario_s4_booking_overlap():
|
||||||
|
"""S4: MO-016 carries two overlapping reservations plus a matching open issue."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
vehicle = _by_ref(db, Vehicle, "MO-016")
|
||||||
|
booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A")
|
||||||
|
booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B")
|
||||||
|
assert vehicle is not None and booking_a is not None and booking_b is not None
|
||||||
|
assert booking_a.vehicle_id == vehicle.id
|
||||||
|
assert booking_b.vehicle_id == vehicle.id
|
||||||
|
assert booking_a.starts_at < booking_b.ends_at
|
||||||
|
assert booking_b.starts_at < booking_a.ends_at
|
||||||
|
|
||||||
|
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP")
|
||||||
|
assert issue is not None
|
||||||
|
assert issue.rule_type == "booking_overlap"
|
||||||
|
assert issue.status == "open"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_scenario_s5_failed_workflow_run():
|
||||||
|
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
|
||||||
|
pending, so the background dispatcher never silently auto-heals it away."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
failed = db.scalar(
|
||||||
|
select(OutboxEvent).where(
|
||||||
|
OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert failed is not None
|
||||||
|
assert failed.delivery_status == "failed"
|
||||||
|
assert failed.attempts >= 1
|
||||||
|
assert failed.last_error
|
||||||
|
assert failed.last_error_code == "connectionError"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_dates_are_anchored_to_reset_moment():
|
||||||
|
"""Every reset shifts seeded dates by (real today - authored anchor), so scenario
|
||||||
|
bookings stay 'today'/'near-future' relative to whenever the reset actually ran,
|
||||||
|
instead of decaying back to the fixed 2026-08-01 authoring date."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = reset_and_seed(db)
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
assert result.anchor_date == today
|
||||||
|
|
||||||
|
shift = today - SEED_AUTHORED_ANCHOR
|
||||||
|
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||||
|
assert booking is not None
|
||||||
|
# Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the
|
||||||
|
# real reset date, not the frozen authoring date (unless shift is exactly zero).
|
||||||
|
assert booking.ends_at.date() == today or shift.days == 0
|
||||||
|
|
||||||
|
marker = db.scalar(
|
||||||
|
select(AuditEvent)
|
||||||
|
.where(AuditEvent.action == "demo_data_seeded")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
)
|
||||||
|
assert marker is not None
|
||||||
|
assert marker.metadata_json["anchor_date"] == today.isoformat()
|
||||||
|
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Pure unit tests for the shared vehicle-status evaluator -- no database needed, since
|
||||||
|
evaluate_vehicle_status() only reasons over an already-gathered VehicleStatusFacts. See
|
||||||
|
docs/fleet-ops-correction/vehicle-status-decision-table.md for the decision table these
|
||||||
|
tests are asserting against."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.services.vehicle_status import (
|
||||||
|
RECOMMENDATION_CODE_ACTIVE_RENTAL,
|
||||||
|
RECOMMENDATION_CODE_BOOKING_CONFLICT,
|
||||||
|
RECOMMENDATION_CODE_MANUAL_REVIEW,
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
RECOMMENDATION_CODE_RENTAL_ENDED,
|
||||||
|
RECOMMENDATION_CODE_SERVICE_THRESHOLD,
|
||||||
|
VehicleStatusFacts,
|
||||||
|
compute_recommendation_token,
|
||||||
|
evaluate_vehicle_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vehicle(status: str, *, version: int = 1):
|
||||||
|
return SimpleNamespace(operational_status=status, version=version)
|
||||||
|
|
||||||
|
|
||||||
|
def _facts(**overrides) -> VehicleStatusFacts:
|
||||||
|
defaults = dict(
|
||||||
|
active_booking_refs=[],
|
||||||
|
overlapping_booking_pairs=[],
|
||||||
|
service_threshold_reached=False,
|
||||||
|
odometer_km=10_000,
|
||||||
|
next_service_km=20_000,
|
||||||
|
open_booking_overlap_issue_ref=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return VehicleStatusFacts(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_with_active_rental_recommends_rented():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "rented"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
|
||||||
|
assert result.safe_to_apply is True
|
||||||
|
assert result.manual_review_required is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_maintenance_with_active_rental_never_auto_recommends_rented():
|
||||||
|
# The exact unsafe shortcut this task explicitly forbids: maintenance + an active
|
||||||
|
# booking must NEVER be auto-resolved to "rented".
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("maintenance"), _facts(active_booking_refs=["BK-0001"])
|
||||||
|
)
|
||||||
|
assert result.recommended_status is None
|
||||||
|
assert result.manual_review_required is True
|
||||||
|
assert result.safe_to_apply is False
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_MANUAL_REVIEW
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_with_active_rental_and_service_threshold_requires_manual_review():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"),
|
||||||
|
_facts(active_booking_refs=["BK-0001"], service_threshold_reached=True),
|
||||||
|
)
|
||||||
|
assert result.manual_review_required is True
|
||||||
|
assert result.recommended_status is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_with_active_rental_and_booking_conflict_requires_manual_review():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"),
|
||||||
|
_facts(active_booking_refs=["BK-0001"], open_booking_overlap_issue_ref="DQ-0001"),
|
||||||
|
)
|
||||||
|
assert result.manual_review_required is True
|
||||||
|
assert result.recommended_status is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rented_with_no_active_booking_recommends_available():
|
||||||
|
result = evaluate_vehicle_status(_vehicle("rented"), _facts())
|
||||||
|
assert result.recommended_status == "available"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_RENTAL_ENDED
|
||||||
|
assert result.safe_to_apply is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_threshold_reached_recommends_maintenance():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"), _facts(service_threshold_reached=True)
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "maintenance"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def test_booking_conflict_recommends_blocked_not_a_generic_high_severity_proxy():
|
||||||
|
# The evaluator must react to a *real* booking-conflict fact, not "does some other
|
||||||
|
# open high-severity issue happen to exist" (the forbidden proxy).
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"),
|
||||||
|
_facts(overlapping_booking_pairs=[("BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B")]),
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "blocked"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_booking_overlap_issue_alone_also_triggers_blocked():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"), _facts(open_booking_overlap_issue_ref="DQ-DEMO-OVERLAP")
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "blocked"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
|
||||||
|
|
||||||
|
|
||||||
|
def test_maintenance_with_no_active_rental_and_no_blockers_stays_manual():
|
||||||
|
# No fact here confirms maintenance is actually finished, so the evaluator must not
|
||||||
|
# auto-clear it to "available" -- that release remains an explicit, manual decision.
|
||||||
|
result = evaluate_vehicle_status(_vehicle("maintenance"), _facts())
|
||||||
|
assert result.recommended_status is None
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
assert result.safe_to_apply is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_conflict_when_status_already_matches_facts():
|
||||||
|
result = evaluate_vehicle_status(_vehicle("available"), _facts())
|
||||||
|
assert result.recommended_status is None
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
assert result.safe_to_apply is False
|
||||||
|
|
||||||
|
result = evaluate_vehicle_status(_vehicle("rented"), _facts(active_booking_refs=["BK-1"]))
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
|
||||||
|
result = evaluate_vehicle_status(_vehicle("blocked"), _facts())
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("maintenance"), _facts(service_threshold_reached=True)
|
||||||
|
)
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendation_token_changes_when_facts_change():
|
||||||
|
vehicle = _vehicle("available")
|
||||||
|
facts_a = _facts()
|
||||||
|
facts_b = _facts(service_threshold_reached=True)
|
||||||
|
assert compute_recommendation_token(vehicle, facts_a) != compute_recommendation_token(
|
||||||
|
vehicle, facts_b
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendation_token_is_stable_for_identical_facts():
|
||||||
|
vehicle = _vehicle("available")
|
||||||
|
facts = _facts(active_booking_refs=["BK-0001"])
|
||||||
|
assert compute_recommendation_token(vehicle, facts) == compute_recommendation_token(
|
||||||
|
vehicle, facts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendation_token_changes_when_vehicle_version_changes():
|
||||||
|
facts = _facts()
|
||||||
|
assert compute_recommendation_token(
|
||||||
|
_vehicle("available", version=1), facts
|
||||||
|
) != compute_recommendation_token(_vehicle("available", version=2), facts)
|
||||||
@@ -24,7 +24,6 @@ services:
|
|||||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
|
||||||
TZ: ${TZ:-Europe/Brussels}
|
TZ: ${TZ:-Europe/Brussels}
|
||||||
APP_SECRET: ${APP_SECRET:-replace-in-production}
|
APP_SECRET: ${APP_SECRET:-replace-in-production}
|
||||||
DEMO_TODAY: ${DEMO_TODAY:-2026-08-01}
|
|
||||||
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
|
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
|
||||||
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
|
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
|
||||||
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000}
|
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000}
|
||||||
@@ -32,9 +31,14 @@ services:
|
|||||||
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
|
||||||
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
|
||||||
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
|
||||||
|
RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-}
|
||||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
||||||
|
N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
|
||||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||||
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
||||||
|
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||||
|
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
|
||||||
|
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
|
||||||
ports:
|
ports:
|
||||||
- "8128:8000"
|
- "8128:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: MobilityOps API
|
title: Fleet Ops API
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: Contract baseline for the MobilityOps proof of concept.
|
description: >-
|
||||||
|
Contract baseline for the Fleet Ops demo. "Fleet Ops" is the visible product name;
|
||||||
|
"mobilityops" remains the technical identifier for the repository, deployment
|
||||||
|
directory, database, and internal service/health identifiers only.
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8128
|
- url: http://localhost:8128
|
||||||
paths:
|
paths:
|
||||||
@@ -66,6 +69,26 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Booking detail
|
description: Booking detail
|
||||||
|
/api/v1/bookings/{public_ref}/return-preview:
|
||||||
|
post:
|
||||||
|
operationId: previewVehicleReturn
|
||||||
|
description: >-
|
||||||
|
Non-mutating evaluation of what committing this return would do. Shares its
|
||||||
|
domain evaluation with the commit endpoint below so the two can never drift.
|
||||||
|
No writes, no audit event, no outbox event.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PublicRef'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegisterReturnRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Authoritative evaluation of the resulting fleet state
|
||||||
|
'409':
|
||||||
|
description: Booking is not active
|
||||||
/api/v1/bookings/{public_ref}/return:
|
/api/v1/bookings/{public_ref}/return:
|
||||||
post:
|
post:
|
||||||
operationId: registerVehicleReturn
|
operationId: registerVehicleReturn
|
||||||
@@ -94,9 +117,17 @@ paths:
|
|||||||
/api/v1/data-quality/issues:
|
/api/v1/data-quality/issues:
|
||||||
get:
|
get:
|
||||||
operationId: listDataQualityIssues
|
operationId: listDataQualityIssues
|
||||||
|
description: Operations Manager only.
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Quality issues
|
description: Quality issues
|
||||||
|
/api/v1/data-quality/scan:
|
||||||
|
post:
|
||||||
|
operationId: runDataQualityScan
|
||||||
|
description: Manual trigger for the deterministic five-rule scan. Operations Manager only.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Counts of newly created issues per rule type
|
||||||
/api/v1/data-quality/issues/{public_ref}/merge-customers:
|
/api/v1/data-quality/issues/{public_ref}/merge-customers:
|
||||||
post:
|
post:
|
||||||
operationId: mergeDuplicateCustomers
|
operationId: mergeDuplicateCustomers
|
||||||
@@ -107,6 +138,147 @@ paths:
|
|||||||
description: Merge completed
|
description: Merge completed
|
||||||
'409':
|
'409':
|
||||||
description: Issue no longer mergeable
|
description: Issue no longer mergeable
|
||||||
|
/api/v1/data-quality/issues/{public_ref}/provide-fields:
|
||||||
|
post:
|
||||||
|
operationId: provideMissingFields
|
||||||
|
description: >-
|
||||||
|
missing_required_field only. Resolves once nothing required remains missing;
|
||||||
|
otherwise leaves the issue open with updated evidence.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PublicRef'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Issue after the update (may still be open)
|
||||||
|
'409':
|
||||||
|
description: Wrong rule type or issue not open
|
||||||
|
'422':
|
||||||
|
description: Disallowed field or empty value
|
||||||
|
/api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression:
|
||||||
|
post:
|
||||||
|
operationId: resolveOdometerRegression
|
||||||
|
description: >-
|
||||||
|
odometer_regression only. Either retains the canonical odometer, or corrects a
|
||||||
|
related booking's reading -- a correction below the current canonical value is
|
||||||
|
rejected, since it would not resolve the regression.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PublicRef'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Issue resolved
|
||||||
|
'409':
|
||||||
|
description: Wrong rule type or issue not open
|
||||||
|
'422':
|
||||||
|
description: Invalid booking reference or a correction below canonical
|
||||||
|
/api/v1/data-quality/issues/{public_ref}/resolve-overlap:
|
||||||
|
post:
|
||||||
|
operationId: resolveBookingOverlap
|
||||||
|
description: >-
|
||||||
|
booking_overlap only. Blocks one of the two overlapping bookings and
|
||||||
|
re-verifies no overlap remains before resolving.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PublicRef'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Issue resolved
|
||||||
|
'409':
|
||||||
|
description: Wrong rule type, issue not open, or overlap still present
|
||||||
|
'422':
|
||||||
|
description: booking_ref not one of the overlapping bookings
|
||||||
|
/api/v1/data-quality/issues/{public_ref}/status-recommendation:
|
||||||
|
post:
|
||||||
|
operationId: previewVehicleStatusRecommendation
|
||||||
|
description: >-
|
||||||
|
vehicle_status_conflict only. Non-mutating: computes the recommendation from
|
||||||
|
the same shared evaluator the scanner and apply endpoint use
|
||||||
|
(app.services.vehicle_status.evaluate_vehicle_status), without resolving the
|
||||||
|
issue, writing an audit event, or queuing automation. Safe to call repeatedly
|
||||||
|
-- see docs/fleet-ops-correction/vehicle-status-decision-table.md.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PublicRef'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: >-
|
||||||
|
Current/recommended status, recommendation code, safe_to_apply,
|
||||||
|
manual_review_required, the underlying facts, and a recommendation_token
|
||||||
|
the apply endpoint revalidates against.
|
||||||
|
'409':
|
||||||
|
description: Wrong rule type, issue not open, or vehicle not found
|
||||||
|
/api/v1/data-quality/issues/{public_ref}/apply-recommended-status:
|
||||||
|
post:
|
||||||
|
operationId: applyRecommendedVehicleStatus
|
||||||
|
description: >-
|
||||||
|
vehicle_status_conflict only. Applies the one authoritative recommendation
|
||||||
|
function's output within one transaction: locks the issue and vehicle,
|
||||||
|
recomputes the recommendation from fresh facts, rejects the request if the
|
||||||
|
supplied recommendation_token no longer matches (RECOMMENDATION_STALE), refuses
|
||||||
|
an unsafe/manual-review recommendation (MANUAL_REVIEW_REQUIRED) or a
|
||||||
|
recommendation with nothing to apply (NO_CONFLICT_DETECTED), then re-validates
|
||||||
|
the same evaluator post-write before resolving the issue.
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/PublicRef'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [recommendation_token]
|
||||||
|
properties:
|
||||||
|
recommendation_token:
|
||||||
|
type: string
|
||||||
|
description: The token from the most recent status-recommendation preview call.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Applied status, reason code and the resolved issue
|
||||||
|
'409':
|
||||||
|
description: >-
|
||||||
|
Wrong rule type, issue not open, vehicle not found, stale recommendation
|
||||||
|
token, manual review required, no conflict detected, or the applied status
|
||||||
|
did not resolve the conflict on re-validation
|
||||||
|
/api/v1/search:
|
||||||
|
get:
|
||||||
|
operationId: search
|
||||||
|
description: >-
|
||||||
|
Bounded typed results (vehicle, booking, data_quality_issue, section).
|
||||||
|
Data-quality and manager-only sections are filtered server-side by role.
|
||||||
|
Customers are never returned -- no customer detail route exists. Every result's
|
||||||
|
`label` is a stable public_ref/section id (never translatable prose); `detail_code`
|
||||||
|
(+ optional `detail_params` for data values like make/model/location) is what the
|
||||||
|
frontend localizes -- the backend never emits English/Dutch/French sentences here.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: q
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 100
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Search results
|
||||||
|
/api/v1/integrations/status:
|
||||||
|
get:
|
||||||
|
operationId: getIntegrationStatus
|
||||||
|
description: >-
|
||||||
|
Truthful aggregate n8n state derived from outbox delivery counts (not just the
|
||||||
|
most recent event), plus the actual MCP Hub registration_enabled setting.
|
||||||
|
Operations Manager only.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: n8n and MCP Hub integration status
|
||||||
|
/api/v1/integrations/n8n/scheduled-scan:
|
||||||
|
post:
|
||||||
|
operationId: n8nScheduledScan
|
||||||
|
description: >-
|
||||||
|
Triggered by the scheduled n8n quality-scan workflow. Runs the same run_scan()
|
||||||
|
the manual UI action uses; idempotent by construction.
|
||||||
|
security:
|
||||||
|
- serviceToken: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Counts of newly created issues per rule type
|
||||||
|
'401':
|
||||||
|
description: Invalid service token
|
||||||
/api/v1/knowledge/questions:
|
/api/v1/knowledge/questions:
|
||||||
post:
|
post:
|
||||||
operationId: askKnowledgeQuestion
|
operationId: askKnowledgeQuestion
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -eu
|
|||||||
|
|
||||||
container_name="${1:-n8n}"
|
container_name="${1:-n8n}"
|
||||||
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
|
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
|
||||||
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
|
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "Missing deployment .env" >&2
|
echo "Missing deployment .env" >&2
|
||||||
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
|
# The workflow file no longer carries the callback token as a literal header value -- both
|
||||||
if [ -z "$callback_token" ]; then
|
# the webhook trigger and the outbound callback authenticate via named n8n Header Auth
|
||||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
# credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
|
||||||
exit 1
|
# exist in the target n8n instance before this workflow is activated; see the echo below.
|
||||||
fi
|
|
||||||
|
|
||||||
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||||
container_workflow="/tmp/mobilityops-return-processing.json"
|
container_workflow="/tmp/mobilityops-return-processing.json"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -32,15 +30,17 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
|
jq --arg callback_url "$callback_url" '
|
||||||
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
|
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
|
||||||
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
|
|
||||||
select(.name == "X-Service-Token") | .value) = $callback_token
|
|
||||||
' "$source_workflow" > "$temporary_workflow"
|
' "$source_workflow" > "$temporary_workflow"
|
||||||
|
|
||||||
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
||||||
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
|
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
|
||||||
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
|
|
||||||
docker restart "$container_name" >/dev/null
|
|
||||||
|
|
||||||
echo "Published MobilityOps return workflow to existing container ${container_name}"
|
echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
|
||||||
|
echo "Before activating: in the n8n UI, create Header Auth credentials named"
|
||||||
|
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
|
||||||
|
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
|
||||||
|
echo "then open the workflow and click Publish. This script does not print or transmit"
|
||||||
|
echo "those secret values, and does not restart the container -- restart it yourself once"
|
||||||
|
echo "credentials are wired up and the workflow is published, if required."
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
container_name="${1:-n8n}"
|
||||||
|
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
|
||||||
|
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Missing deployment .env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$source_workflow" ]; then
|
||||||
|
echo "Missing workflow export: $source_workflow" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker inspect "$container_name" >/dev/null 2>&1; then
|
||||||
|
echo "Existing n8n container not found: $container_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The workflow file no longer carries the callback token as a literal header value -- the
|
||||||
|
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
|
||||||
|
# Token"), which must exist in the target n8n instance before this workflow is activated;
|
||||||
|
# see the echo below.
|
||||||
|
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
|
||||||
|
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$temporary_workflow"
|
||||||
|
docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
jq --arg scan_url "$scan_url" '
|
||||||
|
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
|
||||||
|
' "$source_workflow" > "$temporary_workflow"
|
||||||
|
|
||||||
|
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
|
||||||
|
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
|
||||||
|
|
||||||
|
echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
|
||||||
|
echo "Before activating: in the n8n UI, create a Header Auth credential named"
|
||||||
|
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
|
||||||
|
echo "then open the workflow and click Publish. This script does not print or transmit"
|
||||||
|
echo "that secret value, and does not restart the container -- restart it yourself once"
|
||||||
|
echo "the credential is wired up and the workflow is published, if required."
|
||||||
@@ -11,9 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
|
|||||||
### System and demo
|
### System and demo
|
||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /api/v1/system/status`
|
- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data
|
||||||
|
flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with
|
||||||
|
live readiness derived from actual records, not hardcoded), and plain-language
|
||||||
|
integration summaries. Single source of truth for the demo-entry screen, the permanent
|
||||||
|
demo badge, the scenario overview and the About page — avoids duplicating this logic
|
||||||
|
per surface.
|
||||||
- `POST /api/v1/demo/login`
|
- `POST /api/v1/demo/login`
|
||||||
- `POST /api/v1/demo/reset` — Operations Manager only
|
- `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store`
|
||||||
|
- `POST /api/v1/demo/logout` — safe to call without a session
|
||||||
|
- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own
|
||||||
|
session; returns 403 if `DEMO_ALLOW_RESET=false`
|
||||||
|
|
||||||
### Dashboard
|
### Dashboard
|
||||||
|
|
||||||
@@ -28,17 +36,37 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
|
|||||||
|
|
||||||
- `GET /api/v1/bookings`
|
- `GET /api/v1/bookings`
|
||||||
- `GET /api/v1/bookings/{public_ref}`
|
- `GET /api/v1/bookings/{public_ref}`
|
||||||
|
- `POST /api/v1/bookings/{public_ref}/return-preview` — non-mutating; shares its domain
|
||||||
|
evaluation with the commit endpoint below so the two cannot drift apart
|
||||||
- `POST /api/v1/bookings/{public_ref}/return`
|
- `POST /api/v1/bookings/{public_ref}/return`
|
||||||
|
|
||||||
Return commands require an `Idempotency-Key` header and optimistic version where relevant.
|
Return commands require an `Idempotency-Key` header. Concurrency safety is row-lock based
|
||||||
|
(`SELECT ... FOR UPDATE` on the booking and vehicle); no optimistic-version field is
|
||||||
|
accepted or needed on top of that.
|
||||||
|
|
||||||
### Data quality
|
### Data quality
|
||||||
|
|
||||||
|
All Operations Manager only.
|
||||||
|
|
||||||
- `GET /api/v1/data-quality/issues`
|
- `GET /api/v1/data-quality/issues`
|
||||||
- `GET /api/v1/data-quality/issues/{public_ref}`
|
- `GET /api/v1/data-quality/issues/{public_ref}`
|
||||||
|
- `POST /api/v1/data-quality/scan` — manual trigger for the deterministic five-rule scan
|
||||||
- `POST /api/v1/data-quality/issues/{public_ref}/defer`
|
- `POST /api/v1/data-quality/issues/{public_ref}/defer`
|
||||||
- `POST /api/v1/data-quality/issues/{public_ref}/reject`
|
- `POST /api/v1/data-quality/issues/{public_ref}/reject`
|
||||||
- `POST /api/v1/data-quality/issues/{public_ref}/merge-customers`
|
- `POST /api/v1/data-quality/issues/{public_ref}/merge-customers` — possible_duplicate_customer
|
||||||
|
- `POST /api/v1/data-quality/issues/{public_ref}/provide-fields` — missing_required_field
|
||||||
|
- `POST /api/v1/data-quality/issues/{public_ref}/resolve-odometer-regression` — odometer_regression
|
||||||
|
- `POST /api/v1/data-quality/issues/{public_ref}/resolve-overlap` — booking_overlap
|
||||||
|
- `POST /api/v1/data-quality/issues/{public_ref}/apply-recommended-status` — vehicle_status_conflict
|
||||||
|
|
||||||
|
Each of the five rule types has exactly one bounded resolution path above (plus
|
||||||
|
defer/reject, which apply to any open issue).
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
- `GET /api/v1/search?q=...` — bounded typed results (vehicle, booking,
|
||||||
|
data_quality_issue, section); role-filtered server-side; customers are never returned
|
||||||
|
(no customer detail route exists in this PoC)
|
||||||
|
|
||||||
### Knowledge
|
### Knowledge
|
||||||
|
|
||||||
@@ -47,9 +75,21 @@ Return commands require an `Idempotency-Key` header and optimistic version where
|
|||||||
|
|
||||||
### Automation and audit
|
### Automation and audit
|
||||||
|
|
||||||
- `GET /api/v1/workflows`
|
- `GET /api/v1/workflows` — Operations Manager only
|
||||||
- `POST /api/v1/workflows/{event_id}/retry`
|
- `POST /api/v1/workflows/{event_id}/retry` — Operations Manager only
|
||||||
- `GET /api/v1/audit`
|
- `GET /api/v1/audit` — Operations Manager only; each event includes `before`/`after`
|
||||||
|
plus a resolved `entity_ref`/`entity_link` where the entity type supports one
|
||||||
|
- `GET /api/v1/integrations/status` — Operations Manager only; truthful aggregate n8n
|
||||||
|
state from outbox delivery counts (not just the most recent event), and the actual
|
||||||
|
MCP Hub `registration_enabled` setting
|
||||||
|
|
||||||
|
### n8n-service endpoints
|
||||||
|
|
||||||
|
Service-token protected (`X-Service-Token`, same shared secret as the return callback):
|
||||||
|
|
||||||
|
- `POST /api/v1/integrations/n8n/return-callback`
|
||||||
|
- `POST /api/v1/integrations/n8n/scheduled-scan` — triggered by the scheduled
|
||||||
|
quality-scan workflow; runs the same domain scan the manual UI action uses
|
||||||
|
|
||||||
### MCP-provider endpoints
|
### MCP-provider endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -25,14 +25,29 @@ Merge rewires booking references, preserves the loser as a tombstone and audits
|
|||||||
|
|
||||||
Required for active customers: first name, last name and at least one of email or phone. Required for active vehicles: registration number, make, model and location.
|
Required for active customers: first name, last name and at least one of email or phone. Required for active vehicles: registration number, make, model and location.
|
||||||
|
|
||||||
|
Resolution: `POST /provide-fields` accepts only the fields the entity type actually
|
||||||
|
requires (rejects anything else), applies them, and re-runs the same missing-field check.
|
||||||
|
The issue resolves only once nothing required remains missing; a partial submission
|
||||||
|
updates the record and its evidence but leaves the issue open.
|
||||||
|
|
||||||
## DQ-03 Odometer regression
|
## DQ-03 Odometer regression
|
||||||
|
|
||||||
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically.
|
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically.
|
||||||
|
|
||||||
|
Resolution: `POST /resolve-odometer-regression` offers exactly two bounded decisions —
|
||||||
|
`retain_canonical` (the submitted reading is treated as erroneous; canonical is
|
||||||
|
untouched) or `correct_reading` (updates a named related booking's reading and the
|
||||||
|
vehicle's canonical odometer together). A `correct_reading` value below the current
|
||||||
|
canonical is rejected, since it would not resolve the regression, not silently applied.
|
||||||
|
|
||||||
## DQ-04 Booking overlap
|
## DQ-04 Booking overlap
|
||||||
|
|
||||||
Flag overlapping `reserved` or `active` bookings for one vehicle. Normal write APIs reject new overlaps; the seed/import path may create one controlled legacy conflict.
|
Flag overlapping `reserved` or `active` bookings for one vehicle. Normal write APIs reject new overlaps; the seed/import path may create one controlled legacy conflict.
|
||||||
|
|
||||||
|
Resolution: `POST /resolve-overlap` blocks one of the two named overlapping bookings
|
||||||
|
(minimal safe resolution, not a scheduling calendar) and re-verifies no
|
||||||
|
reserved/active overlap remains among the issue's related bookings before resolving.
|
||||||
|
|
||||||
## DQ-05 Vehicle status conflict
|
## DQ-05 Vehicle status conflict
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -42,6 +57,16 @@ Examples:
|
|||||||
- status `available` while critical open quality issue exists;
|
- status `available` while critical open quality issue exists;
|
||||||
- status `maintenance` with an active booking.
|
- status `maintenance` with an active booking.
|
||||||
|
|
||||||
|
Resolution: `POST /apply-recommended-status` computes a recommendation from one
|
||||||
|
authoritative function mirroring the conditions above, applies it, and re-runs the same
|
||||||
|
function to confirm the conflict is actually gone before resolving.
|
||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
Detection is idempotent by `(rule_type, entity_type, entity_id, evidence fingerprint)` while open. Resolved issues remain historical. Reintroduced evidence creates a new issue linked to the prior issue where useful.
|
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open — the CSV
|
||||||
|
seed rows don't carry a stable evidence fingerprint, so the literal
|
||||||
|
`(..., evidence fingerprint)` scheme from an earlier draft of this rule was dropped as
|
||||||
|
unworkable for seeded data; re-implementing it would need to reconcile with that. Resolved
|
||||||
|
issues remain historical. Reintroduced evidence creates a new issue whose evidence carries
|
||||||
|
`reopened_from` (the prior issue's reference) and `previous_decision` (its resolved
|
||||||
|
status), so a repeat problem is never presented as if no decision was ever made.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Vehicle-return workflow
|
# Vehicle-return workflow
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|
`POST /api/v1/bookings/{public_ref}/return-preview` takes the same request body as the
|
||||||
|
commit endpoint below and runs the identical evaluation (`evaluate_return()`) with no
|
||||||
|
writes, no audit event and no outbox event — it exists so the UI's review step shows the
|
||||||
|
server's actual answer instead of guessing the outcome client-side. It returns the
|
||||||
|
canonical and submitted odometer readings, whether the submission is a regression, the
|
||||||
|
resulting vehicle status with a human-readable reason, whether a quality issue would be
|
||||||
|
created, and next-booking risk. `register_vehicle_return` (below) calls the same
|
||||||
|
`evaluate_return()` function, so preview and commit cannot drift apart.
|
||||||
|
|
||||||
## Input
|
## Input
|
||||||
|
|
||||||
- booking public reference;
|
- booking public reference;
|
||||||
|
|||||||
@@ -16,19 +16,47 @@ Steps:
|
|||||||
4. return a stable workflow result;
|
4. return a stable workflow result;
|
||||||
5. on errors, fail visibly so the outbox dispatcher can retry.
|
5. on errors, fail visibly so the outbox dispatcher can retry.
|
||||||
|
|
||||||
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency.
|
The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
|
||||||
|
|
||||||
## Optional second workflow: knowledge sync
|
## Second live workflow: scheduled quality scan
|
||||||
|
|
||||||
Input: manual trigger or manifest-changed event.
|
RAGcore is not connected in this environment, so the originally sketched "knowledge sync"
|
||||||
|
workflow below remains deferred (see "Deferred: knowledge sync"). The second implemented
|
||||||
|
workflow does not depend on RAGcore or MCP Hub, so it is not blocked by them.
|
||||||
|
|
||||||
|
Input: hourly schedule trigger, or a manual trigger for on-demand testing.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
|
|
||||||
1. read the fixed knowledge manifest;
|
1. call the narrow, service-token-protected `POST
|
||||||
2. call RAGcore ingestion/sync API;
|
/api/v1/integrations/n8n/scheduled-scan` endpoint;
|
||||||
3. record per-document results through MobilityOps integration status API.
|
2. the endpoint runs the same deterministic `run_scan()` domain function the manual
|
||||||
|
"Run quality scan" UI action uses, and records a `data_quality_scan_run` audit event
|
||||||
|
with `actor_type=service`;
|
||||||
|
3. return counts of newly created issues per rule type.
|
||||||
|
|
||||||
This workflow is useful but must not delay the core demo if RAGcore's final API is not ready.
|
`run_scan()` only ever creates an issue for a condition that does not already have one
|
||||||
|
open, so a duplicate or overlapping trigger (a manual test run firing close to the
|
||||||
|
scheduled one, or a retried HTTP call) does no duplicate domain work.
|
||||||
|
|
||||||
|
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||||
|
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
|
||||||
|
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
|
||||||
|
active on the live instance; a fresh import ships inactive until credentials are wired up
|
||||||
|
and it is deliberately published.
|
||||||
|
|
||||||
|
## RAGcore procedure sync (in progress)
|
||||||
|
|
||||||
|
RAGcore is now reachable in this environment; a live inspection of its real contract is
|
||||||
|
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
|
||||||
|
RAGcore Procedure Sync", is being built against that real contract (not the sketch
|
||||||
|
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
|
||||||
|
|
||||||
|
## Workflow error handler (in progress)
|
||||||
|
|
||||||
|
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
|
||||||
|
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
|
||||||
|
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
|
||||||
|
|
||||||
## Outbox dispatcher
|
## Outbox dispatcher
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,30 @@
|
|||||||
|
|
||||||
Role buttons may create a session for a seeded demo identity. All API routes still enforce authorization. Demo reset and customer merge require Operations Manager.
|
Role buttons may create a session for a seeded demo identity. All API routes still enforce authorization. Demo reset and customer merge require Operations Manager.
|
||||||
|
|
||||||
|
The browser never treats its own cached copy of the logged-in user as authoritative:
|
||||||
|
`AuthContext` re-verifies against `GET /api/v1/demo/session` on every app load (that
|
||||||
|
response is `Cache-Control: no-store`, so a stale cached "authenticated" response can't
|
||||||
|
survive a logout), and a central 401 listener on the API client clears local auth state
|
||||||
|
from any endpoint, not just the session check. `POST /api/v1/demo/logout` and
|
||||||
|
`POST /api/v1/demo/reset` both invalidate the session cookie server-side.
|
||||||
|
|
||||||
|
## Role matrix
|
||||||
|
|
||||||
|
| Capability | Rental Employee | Operations Manager |
|
||||||
|
|---|---|---|
|
||||||
|
| Dashboard, fleet, vehicle detail, bookings, booking detail | yes | yes |
|
||||||
|
| Register a vehicle return | yes | yes |
|
||||||
|
| Knowledge assistant | yes | yes |
|
||||||
|
| Data-quality workbench (view, scan, all resolutions) | no | yes |
|
||||||
|
| Integrations / automation status and retry | no | yes |
|
||||||
|
| Audit trail | no | yes |
|
||||||
|
| Demo reset | no | yes |
|
||||||
|
|
||||||
|
Enforced server-side (every listed manager-only action returns `403` for Rental
|
||||||
|
Employee, verified by direct API tests, not just a hidden button) and mirrored in the
|
||||||
|
frontend nav (manager-only items are not rendered, not merely disabled) and route guards
|
||||||
|
(direct URL access shows a restricted message rather than partial data).
|
||||||
|
|
||||||
## Service authentication
|
## Service authentication
|
||||||
|
|
||||||
Use separate scoped credentials for:
|
Use separate scoped credentials for:
|
||||||
@@ -42,6 +66,13 @@ Required actions:
|
|||||||
|
|
||||||
Audit is append-only through the application. Provide filters by actor, action, entity and correlation ID.
|
Audit is append-only through the application. Provide filters by actor, action, entity and correlation ID.
|
||||||
|
|
||||||
|
`GET /api/v1/audit` (Operations Manager only) returns `before`/`after` for every event
|
||||||
|
(the columns already existed but were not serialized until this pass) plus a resolved
|
||||||
|
`entity_ref`/`entity_link` for vehicle, booking and data-quality-issue entities (no
|
||||||
|
customer link exists — no customer detail route). The UI shows a human-readable
|
||||||
|
before/after summary per row by default, with the raw before/after/metadata JSON behind
|
||||||
|
a `<details>` disclosure rather than shown unconditionally.
|
||||||
|
|
||||||
## Confirmation
|
## Confirmation
|
||||||
|
|
||||||
No write-capable MCP actions exist in this PoC. Destructive UI actions such as demo reset and customer merge require explicit confirmation.
|
No write-capable MCP actions exist in this PoC. Destructive UI actions such as demo reset and customer merge require explicit confirmation.
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err
|
|||||||
|
|
||||||
Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures.
|
Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures.
|
||||||
|
|
||||||
|
## Date anchoring
|
||||||
|
|
||||||
|
The committed CSVs store absolute ISO timestamps authored around a fixed anchor date
|
||||||
|
(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the
|
||||||
|
`--anchor` used to generate them). Every seed/reset shifts every seeded booking,
|
||||||
|
inspection, maintenance and outbox timestamp by `today − SEED_AUTHORED_ANCHOR`, so
|
||||||
|
"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the
|
||||||
|
environment was (re)seeded instead of decaying as real time passes between resets. Public
|
||||||
|
refs and entity relationships are untouched by the shift — only datetime columns move.
|
||||||
|
`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a
|
||||||
|
`demo_data_seeded` audit event carrying both the resolved anchor and the original
|
||||||
|
authoring anchor, so the shift applied on any given reset stays traceable.
|
||||||
|
|
||||||
## Demo reset
|
## Demo reset
|
||||||
|
|
||||||
Reset must:
|
Reset must:
|
||||||
@@ -49,6 +62,7 @@ Reset must:
|
|||||||
- require Operations Manager;
|
- require Operations Manager;
|
||||||
- rebuild the deterministic dataset;
|
- rebuild the deterministic dataset;
|
||||||
- re-establish scenario references;
|
- re-establish scenario references;
|
||||||
|
- re-anchor scenario dates to the real reset moment (see above);
|
||||||
- clear non-seed audit/workflow state;
|
- clear non-seed audit/workflow state;
|
||||||
- complete safely and visibly;
|
- complete safely and visibly;
|
||||||
- be covered by a test.
|
- be covered by a test.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# PoC runbook
|
# PoC runbook
|
||||||
|
|
||||||
|
For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid
|
||||||
|
redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`.
|
||||||
|
This document covers general environment bootstrap and n8n setup.
|
||||||
|
|
||||||
## Bootstrap (clean checkout)
|
## Bootstrap (clean checkout)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
|
|||||||
which runs:
|
which runs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
|
||||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
```
|
```
|
||||||
@@ -52,6 +56,12 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
|
|||||||
(`n8n import:workflow` always leaves the workflow deactivated regardless of its
|
(`n8n import:workflow` always leaves the workflow deactivated regardless of its
|
||||||
`"active"` field; `publish:workflow` + a restart is what actually activates it.)
|
`"active"` field; `publish:workflow` + a restart is what actually activates it.)
|
||||||
|
|
||||||
|
Before it will actually process a return, create two Header Auth credentials in the n8n
|
||||||
|
UI — `Fleet Ops Webhook Trigger Token` (value: `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` from
|
||||||
|
`.env`) and `Fleet Ops Service Token` (value: `MOBILITYOPS_CALLBACK_TOKEN` from `.env`) —
|
||||||
|
the workflow's webhook trigger and outbound HTTP call reference these credentials by
|
||||||
|
name; no secret value is embedded in the workflow file itself.
|
||||||
|
|
||||||
Verify the full round trip:
|
Verify the full round trip:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -62,6 +72,38 @@ curl -b cookies.txt http://localhost:8128/api/v1/workflows | grep succeeded
|
|||||||
A failed/offline n8n does not roll back the return — the outbox event simply stays
|
A failed/offline n8n does not roll back the return — the outbox event simply stays
|
||||||
`pending`/`failed` and is safely retryable from the Automation page.
|
`pending`/`failed` and is safely retryable from the Automation page.
|
||||||
|
|
||||||
|
### Second workflow: scheduled quality scan
|
||||||
|
|
||||||
|
Import and publish the same way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make n8n-setup-scan
|
||||||
|
```
|
||||||
|
|
||||||
|
which runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
|
||||||
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
|
docker compose restart n8n
|
||||||
|
```
|
||||||
|
|
||||||
|
This workflow also needs the `Fleet Ops Service Token` Header Auth credential created in
|
||||||
|
the n8n UI before a run will succeed.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8128/api/v1/integrations/n8n/scheduled-scan \
|
||||||
|
-H "X-Service-Token: <MOBILITYOPS_CALLBACK_TOKEN from .env>"
|
||||||
|
# {"created": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Trigger a live run from n8n's own UI ("Manual test trigger" node → Execute Workflow) to
|
||||||
|
confirm the round trip without waiting for the hourly schedule. It does not depend on
|
||||||
|
RAGcore or MCP Hub and ships `"active": false`, so it never fires anywhere until
|
||||||
|
deliberately published with a real service token.
|
||||||
|
|
||||||
### Existing shared n8n on the Unraid review server
|
### Existing shared n8n on the Unraid review server
|
||||||
|
|
||||||
The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not
|
The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not
|
||||||
@@ -85,6 +127,14 @@ inside n8n's protected application data. The callback travels through the Mobili
|
|||||||
proxy, so the shared n8n container does not need direct database access or membership of
|
proxy, so the shared n8n container does not need direct database access or membership of
|
||||||
the MobilityOps Docker network.
|
the MobilityOps Docker network.
|
||||||
|
|
||||||
|
Publish the scheduled quality-scan workflow the same way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/unraid/setup-scheduled-scan.sh \
|
||||||
|
n8n \
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan
|
||||||
|
```
|
||||||
|
|
||||||
## Required operational checks
|
## Required operational checks
|
||||||
|
|
||||||
- API and web health (`GET /health`, web root `200`);
|
- API and web health (`GET /health`, web root `200`);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Current demo-readiness gap audit
|
||||||
|
|
||||||
|
Performed 2026-08-03 against `feat/mobilityops-functional-completion` @
|
||||||
|
`e0c7ed60112510687627d20a957af91c8b9db7f8` (verified: correct branch, working tree
|
||||||
|
clean, matches the deployed Unraid revision). Method: read `CLAUDE.md`/`AGENTS.md`
|
||||||
|
(identical, no demo-specific guidance yet), `README.md`, `PROJECT_STATE.md`, the docs
|
||||||
|
list in the brief, `artifacts/final-acceptance/summary.md`,
|
||||||
|
`artifacts/functional-completion/final-summary.md`, `seed/generate_seed.py`,
|
||||||
|
`seed/*.csv`, `backend/app/seed_loader.py`, `backend/app/core/config.py`,
|
||||||
|
`backend/app/api/routers/dashboard.py`; live-browsed the deployed app at
|
||||||
|
`http://192.168.10.150:1236` (login, dashboard, a data-quality issue, a booking detail,
|
||||||
|
knowledge, automation).
|
||||||
|
|
||||||
|
## What already works well (do not rebuild)
|
||||||
|
|
||||||
|
- The premium Control Rail UI, role-based nav, server-backed sessions, return preview/
|
||||||
|
commit, all five data-quality resolution flows, audit before/after, role-aware search,
|
||||||
|
truthful integration status, demo reset, stale-lease recovery and the two n8n
|
||||||
|
workflows are all implemented, tested and live-verified per
|
||||||
|
`artifacts/functional-completion/final-summary.md` — this pass does not touch that
|
||||||
|
business logic.
|
||||||
|
- `docs/13-seed-and-demo-scenarios.md` already names exactly the scenarios this brief
|
||||||
|
wants surfaced: **S1** odometer-regression return (`BK-DEMO-RETURN`/`MO-024`), **S2**
|
||||||
|
duplicate customer (`CUS-0012`/`CUS-0178`, issue `DQ-DEMO-DUPLICATE`), **S4** booking
|
||||||
|
overlap (`MO-016`, issue `DQ-DEMO-OVERLAP`, bookings `BK-DEMO-OVERLAP-A/B`), **S5**
|
||||||
|
failed workflow (seeded failed outbox event, retryable from Automation), **S6**
|
||||||
|
grounded knowledge question. These map directly to the brief's 5 requested scenarios
|
||||||
|
— no new scenarios need to be invented, only surfaced with guidance. (S3, "missing
|
||||||
|
inspection before next booking" on `MO-031`, exists too but isn't one of the 5
|
||||||
|
requested; leave it as-is, it still shows up in the attention queue.)
|
||||||
|
- `seed/generate_seed.py` already produces believable Flemish/Dutch data: first/last
|
||||||
|
name pools (Sofie, Lotte, Bram, Peeters, Janssens, Vermeulen, ...), real Kempen-region
|
||||||
|
towns (Geel, Turnhout, Herentals, Mol, Westerlo, Tessenderlo-Ham), `.test` email
|
||||||
|
addresses, and believable RV/camper makes (Adria, Dethleffs, Hymer, Bürstner, ...).
|
||||||
|
Confirmed live: `DQ-DEMO-DUPLICATE` shows customer "Bram Peeters" (Geel) with email
|
||||||
|
`bram.peeters.12@example.test`. **The underlying data quality is already
|
||||||
|
demo-grade — the gap is structural/experiential, not data flavour.**
|
||||||
|
|
||||||
|
## Confirmed gaps
|
||||||
|
|
||||||
|
### 1. No fictional organisation identity anywhere
|
||||||
|
Login/demo-entry screen says "MobilityOps · Control Centre" and generic marketing copy
|
||||||
|
("Every hand-off. One clear view.") but never names a demonstration organisation. A
|
||||||
|
stranger cannot tell *whose* fleet this is. **Confirmed via full-repo search:**
|
||||||
|
"Northstar Mobility" is never rendered in the UI, but it is **already the project's
|
||||||
|
locked fictitious tenant name** — `PROJECT_STATE.md`'s "Locked decisions" section
|
||||||
|
states verbatim *"Fictitious tenant: Northstar Mobility Demo"*, and
|
||||||
|
`ragcore_tenant` defaults to `"northstar-mobility-demo"` in `backend/app/core/config.py`,
|
||||||
|
`compose.yaml`, `.env.example`, `knowledge/manifest.json` and `contracts/mcp-tools.json`.
|
||||||
|
So using "Northstar Mobility" as the visible demo brand (as this brief suggests) is not
|
||||||
|
a new invention — it surfaces a decision the project already made and only ever used
|
||||||
|
internally as a RAGcore-tenant slug. No renaming/config changes needed, just make it
|
||||||
|
visible.
|
||||||
|
|
||||||
|
### 2. No guided path — a visitor has no idea where to start
|
||||||
|
The dashboard shows real attention-queue items (duplicate customer, booking overlap,
|
||||||
|
vehicle status conflict, missing field) and today's movements (`BK-DEMO-RETURN`
|
||||||
|
11:00), but nothing tells a first-time visitor *which* of these is worth clicking, in
|
||||||
|
what order, or why. There is no "start guided demo" affordance, no scenario overview,
|
||||||
|
no step-by-step walkthrough. Everything technically works; nothing narrates.
|
||||||
|
|
||||||
|
### 3. Live date anchoring is broken — confirmed, reproducible
|
||||||
|
`seed/bookings.csv` stores **absolute fixed ISO timestamps** authored around anchor
|
||||||
|
date 2026-08-01 (`generate_seed.py --anchor 2026-08-01`). `backend/app/core/config.py`'s
|
||||||
|
`demo_today` setting (`"2026-08-01"`) is used *only* by
|
||||||
|
`dashboard.py::_today()` to filter "today's movements" against that frozen date — nothing
|
||||||
|
re-anchors the underlying booking/inspection/maintenance/outbox dates themselves.
|
||||||
|
**Live-confirmed right now** (today is 2026-08-03): `BK-DEMO-RETURN` shows status
|
||||||
|
`active` with `ends_at = 01/08/2026 09:00` — already two days in the past, for a
|
||||||
|
booking that's supposed to look "in progress, due for return soon." `BK-DEMO-NEXT`
|
||||||
|
(meant to read as "near-future") starts 2026-08-02, also already past. Every day this
|
||||||
|
environment isn't reset, this gets worse, and after any `seed --reset` it snaps right
|
||||||
|
back to the same frozen 2026-08-01-relative positions regardless of the real reset
|
||||||
|
moment. This is exactly the failure mode brief section 7 describes and must be fixed at
|
||||||
|
the source (`seed_loader.py`), not papered over in the UI.
|
||||||
|
|
||||||
|
### 4. Knowledge assistant names "RAGcore" directly, not honestly "demo mode"
|
||||||
|
`/knowledge` page copy: "Answers are shown only when **RAGcore** returns sufficient
|
||||||
|
cited evidence" and a "RAGcore" label on the sources block — while the actual active
|
||||||
|
provider is `KNOWLEDGE_PROVIDER=demo` (confirmed: badge below correctly shows
|
||||||
|
`demo · Available · 10 procedures indexed`, which contradicts the body copy one line
|
||||||
|
below it). This is the exact misleading-integration-status problem brief section 11
|
||||||
|
warns against — the small badge is honest, the prose isn't. No suggested questions are
|
||||||
|
offered.
|
||||||
|
|
||||||
|
### 5. Integration status labels are technical, not demo-plain-language
|
||||||
|
Automation page shows raw states like `not_configured`/`degraded`/`operational` (English,
|
||||||
|
developer register) rather than the plain-language labels section 12 asks for
|
||||||
|
(Operationeel / Demomodus / Niet gekoppeld / Voorbereid / Verwerking mislukt / Opnieuw
|
||||||
|
proberen mogelijk).
|
||||||
|
|
||||||
|
### 6. Data-quality resolution panels are functionally complete but not narrated
|
||||||
|
Confirmed live on `DQ-DEMO-DUPLICATE`: the compare/merge UI works well, but nothing
|
||||||
|
explains *why this matters operationally* (duplicate billing/contact risk) or what
|
||||||
|
happens next in plain terms before the user acts. Same pattern across the other 4
|
||||||
|
rule-type panels (already reviewed in this session's earlier batch). No "demo scenario"
|
||||||
|
filter exists to surface the curated issues first.
|
||||||
|
|
||||||
|
### 7. No "About this demo" page
|
||||||
|
No page anywhere explains what's real, what's synthetic, what's live vs. demo-mode vs.
|
||||||
|
not-connected, or the architecture/testing approach in plain terms. A visitor exploring
|
||||||
|
alone has no way to self-answer "is this real?"
|
||||||
|
|
||||||
|
### 8. No permanent, explained "synthetic demo" indicator
|
||||||
|
The existing `demo-banner` ("Synthetic demo data · no real customer or vehicle
|
||||||
|
information") is a full-width static bar repeated on every page — present, but static
|
||||||
|
text only, no link to more explanation, no reset-timestamp/anchor info, and (per section
|
||||||
|
4) shouldn't be a dominant warning bar; it should be a subtle, explained badge.
|
||||||
|
|
||||||
|
### 9. Demo reset exists but is not discoverable as a demo feature
|
||||||
|
`POST /api/v1/demo/reset` and its sidebar UI trigger (this session's earlier batch)
|
||||||
|
work correctly and are Operations-Manager-gated, but reset isn't connected to any demo
|
||||||
|
narrative (no "prepare demo again" framing, not reachable from an About page or guide,
|
||||||
|
no post-reset scenario-integrity confirmation beyond the raw counts).
|
||||||
|
|
||||||
|
### 10. No demo manifest / no single source of "what's on right now"
|
||||||
|
Nothing exposes demo mode, scenario list, integration status and guidance availability
|
||||||
|
as one small structured payload the frontend can render consistently (badge tooltip,
|
||||||
|
guide, scenario overview, About page all currently would have to duplicate this
|
||||||
|
knowledge if built independently).
|
||||||
|
|
||||||
|
### 11. Everything is in English; the brief asks for consistent Dutch demo text
|
||||||
|
All existing UI copy is English. The brief explicitly asks for consistent Dutch product
|
||||||
|
text for the demo-facing additions. Recorded decision (no existing doc answers this,
|
||||||
|
and translating the entire already-shipped, already-tested Control Rail UI is out of
|
||||||
|
proportion to "add guidance," would touch dozens of files and contradicts "this is not
|
||||||
|
a redesign"): **new demo-productization surfaces are written in Dutch** (demo entry
|
||||||
|
copy, Demo Guide, scenario overview, About-this-demo page, the demo badge/tooltip,
|
||||||
|
plain-language integration-status labels). **Existing operational screens (Dashboard,
|
||||||
|
Vehicles, Bookings, Data Quality workbench, Audit, Automation internals) stay in
|
||||||
|
English** — they are already shipped, tested, and section 16 explicitly forbids
|
||||||
|
rebuilding working screens without functional reason. This mirrors how a real bilingual
|
||||||
|
guided demo works: narration in one language, software in whatever it already ships in.
|
||||||
|
|
||||||
|
## Scope reminder (not gaps — explicitly out of scope per the brief)
|
||||||
|
|
||||||
|
- No RAGcore or MCP Hub live implementation.
|
||||||
|
- No new business logic in the 5 resolution flows — only explanatory framing around
|
||||||
|
the existing ones.
|
||||||
|
- No full redesign, new component library, new color palette, or full retranslation.
|
||||||
|
|
||||||
|
## Plan (implementation batches, matches task list)
|
||||||
|
|
||||||
|
1. Fix date anchoring in `seed_loader.py` (shift all seeded dates by `today − anchor`
|
||||||
|
at every reset); switch `dashboard.py::_today()` to real wall-clock date; add a
|
||||||
|
`demo_data_seeded` audit marker recording the anchor/reset moment.
|
||||||
|
2. `GET /api/v1/demo/manifest` (org name, demo mode, scenarios, integration status,
|
||||||
|
reset timestamp) + Dutch demo entry screen + permanent demo badge with popover.
|
||||||
|
3. Demo Guide (collapsible panel/bottom sheet, 8 steps) + scenario overview (5 cards)
|
||||||
|
on the dashboard.
|
||||||
|
4. Layer plain-language Dutch explanation onto the return flow, the 5 DQ panels and the
|
||||||
|
knowledge assistant (fix the RAGcore-naming bug), without touching their logic.
|
||||||
|
5. Plain-language integration status labels, richer audit narration for guide-linked
|
||||||
|
events, "Over deze demo" page, reset wired into guide/About/OM menu with a
|
||||||
|
post-reset scenario-integrity check.
|
||||||
|
6. Full guided-demo Playwright test + targeted tests per section 19.
|
||||||
|
7. Clean-checkout demo drill, docs, Unraid deploy, final evidence with screenshots.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Demo concept
|
||||||
|
|
||||||
|
## The fictional problem
|
||||||
|
|
||||||
|
**Northstar Mobility** is a fictional Belgian camper/van rental company with roughly 50
|
||||||
|
vehicles, one main location, a rental team, an operations manager, and a small workshop.
|
||||||
|
Northstar Mobility does not exist — this name and every customer, vehicle, booking and
|
||||||
|
procedure in the demo are synthetic. Before MobilityOps, Northstar Mobility's bookings,
|
||||||
|
returns, customer records and maintenance history lived in spreadsheets and verbal
|
||||||
|
hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked
|
||||||
|
vehicles) only surfaced late, after they had already caused friction. MobilityOps shows
|
||||||
|
how one connected system detects these problems early and lets an Operations Manager
|
||||||
|
resolve them under audit, with automation handling the routine follow-up.
|
||||||
|
|
||||||
|
## Target audience
|
||||||
|
|
||||||
|
Anyone evaluating how MobilityOps approaches operational data-quality and hand-off
|
||||||
|
problems for a small rental fleet: Operations Managers, Rental Employees, and reviewers
|
||||||
|
assessing the approach. No account setup or backend knowledge is required to start —
|
||||||
|
the login screen explains the fictional context and offers a guided path.
|
||||||
|
|
||||||
|
## Demo scope
|
||||||
|
|
||||||
|
This is a focused proof of concept, not a full ERP. In scope: vehicle and booking
|
||||||
|
management, return processing with server-side evaluation, five data-quality detection
|
||||||
|
rules each with one bounded resolution flow, a full audit trail, and orchestration via a
|
||||||
|
real n8n instance. Explicitly out of scope: accounting, payments, public reservations, a
|
||||||
|
generic CRM, inventory, HR, a second RAG stack, or autonomous write actions from any
|
||||||
|
external tool.
|
||||||
|
|
||||||
|
## What's really implemented
|
||||||
|
|
||||||
|
All of the following is functional code, not a mockup or hardcoded screen:
|
||||||
|
|
||||||
|
- Role-based authentication and server-verified sessions (Operations Manager, Rental
|
||||||
|
Employee), enforced both server-side and in the frontend's navigation/route guards.
|
||||||
|
- Vehicle and booking management, with return preview/commit sharing one authoritative
|
||||||
|
evaluation function so they can never drift.
|
||||||
|
- Five data-quality rules (possible duplicate customer, missing required field, odometer
|
||||||
|
regression, booking overlap, vehicle status conflict), each with exactly one bounded,
|
||||||
|
audited resolution endpoint.
|
||||||
|
- A full audit trail with before/after state, actor, correlation IDs, and a
|
||||||
|
"view related events" link reusing that correlation.
|
||||||
|
- Reliable outbox-based delivery to a real n8n instance, with bounded retries and
|
||||||
|
stale-delivery recovery.
|
||||||
|
- Docker Compose deployment and an automated test suite (backend pytest, Ruff, mypy,
|
||||||
|
and a full Playwright end-to-end suite covering the demo experience itself).
|
||||||
|
- A demo manifest (`GET /api/v1/demo/manifest`) as the single source of truth for the
|
||||||
|
demo's identity, synthetic-data status, reset state, scenario readiness, and honest
|
||||||
|
per-integration labels — the frontend never hardcodes what mode it's in.
|
||||||
|
|
||||||
|
## What's synthetic
|
||||||
|
|
||||||
|
The organisation, all customers, vehicles, bookings, maintenance history, the knowledge
|
||||||
|
base's procedures, and the five prepared demo scenarios are entirely fictional. No data
|
||||||
|
refers to a real person, vehicle, or company. Every seeded email uses the `.test`
|
||||||
|
reserved domain. See [`demo-data.md`](demo-data.md) for exactly how this data is
|
||||||
|
generated and kept fresh across resets.
|
||||||
|
|
||||||
|
## What's not yet live-connected
|
||||||
|
|
||||||
|
- **RAGcore**: not connected. The knowledge assistant uses a local, deterministic
|
||||||
|
keyword-matching "demo knowledge base" built from five procedure documents, honestly
|
||||||
|
labelled as such everywhere in the UI (never presented as RAGcore). A
|
||||||
|
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
||||||
|
the same interface once a real RAGcore backend is available — swapping providers is a
|
||||||
|
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
||||||
|
- **ITWorx MCP Hub**: not connected. Registration is disabled by default
|
||||||
|
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" —
|
||||||
|
never a fabricated successful registration.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
- [`demo-scenarios.md`](demo-scenarios.md) — the five guided scenarios and their fixed records.
|
||||||
|
- [`demo-data.md`](demo-data.md) — seed strategy and date-anchoring.
|
||||||
|
- [`demo-guide.md`](demo-guide.md) — the in-app 8-step guided tour.
|
||||||
|
- [`demo-runbook.md`](demo-runbook.md) — how to run, reset, and redeploy the demo.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Demo data strategy
|
||||||
|
|
||||||
|
## Three separate concepts
|
||||||
|
|
||||||
|
It's important to keep these distinct — they solve different problems:
|
||||||
|
|
||||||
|
1. **Deterministic records** — every seeded entity has a stable public reference
|
||||||
|
(`BK-DEMO-RETURN`, `DQ-DEMO-DUPLICATE`, `MO-024`, `CUS-0012`, ...). These references
|
||||||
|
never change between seed generations or resets; they are what the five demo
|
||||||
|
scenarios, the Demo Guide, and `docs/13-seed-and-demo-scenarios.md` all link against.
|
||||||
|
2. **Date anchoring** — the mechanism that keeps "today"/"near-future"/"currently
|
||||||
|
overlapping" scenarios true to whenever the environment was actually last reset,
|
||||||
|
described below.
|
||||||
|
3. **The reset date** — the real wall-clock moment a reset actually happened. This
|
||||||
|
changes every time someone resets; it's the input to date anchoring, not a fixed
|
||||||
|
record.
|
||||||
|
|
||||||
|
## Generating the seed dataset
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python seed/generate_seed.py --anchor 2026-08-01 --seed 20260801
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces the committed CSVs in `seed/*.csv` with **absolute ISO timestamps**
|
||||||
|
authored relative to a fixed anchor date (`2026-08-01`). Target scale: 50 vehicles, 180
|
||||||
|
customers (including three duplicate pairs), ~245 historical/current/future bookings,
|
||||||
|
realistic inspections and maintenance history, and the fixed quality/workflow scenarios
|
||||||
|
described in [`demo-scenarios.md`](demo-scenarios.md). Names, towns and vehicle makes are
|
||||||
|
drawn from believable Flemish/Kempen-region pools; all emails use the `.test` domain.
|
||||||
|
|
||||||
|
## Date anchoring
|
||||||
|
|
||||||
|
`backend/app/seed_loader.py` defines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SEED_AUTHORED_ANCHOR = date(2026, 8, 1) # matches generate_seed.py's --anchor
|
||||||
|
```
|
||||||
|
|
||||||
|
Every seed/reset computes `shift = today - SEED_AUTHORED_ANCHOR` and applies it to every
|
||||||
|
seeded booking, inspection, maintenance and outbox timestamp before insertion. Public
|
||||||
|
references and entity relationships are never touched by the shift — only datetime
|
||||||
|
columns move. This means:
|
||||||
|
|
||||||
|
- `BK-DEMO-RETURN` always ends "today" (or very close to it) relative to whenever you
|
||||||
|
actually reset, not relative to the frozen 2026-08-01 authoring date.
|
||||||
|
- `BK-DEMO-NEXT` and the overlap-scenario bookings always read as "near future".
|
||||||
|
- The shift is recomputed fresh on every reset, so scenarios never decay as real time
|
||||||
|
passes between resets — this was a real, confirmed bug before this fix (see
|
||||||
|
`docs/demo-release/current-demo-gap-audit.md`, gap #3): the environment would drift
|
||||||
|
further out of sync with every day it wasn't reset, and a reset didn't fix it because
|
||||||
|
nothing re-anchored the underlying stored dates.
|
||||||
|
|
||||||
|
`load_seed()` returns the resolved `anchor_date` (real today) and `seeded_at` timestamp,
|
||||||
|
and records a `demo_data_seeded` audit event carrying both the resolved anchor and the
|
||||||
|
original authoring anchor, so the shift applied on any given reset stays traceable via
|
||||||
|
the audit trail.
|
||||||
|
|
||||||
|
`dashboard.py::_today()` uses real wall-clock UTC date (not a frozen setting) to filter
|
||||||
|
"today's movements", consistent with the shifted data.
|
||||||
|
|
||||||
|
## Reset
|
||||||
|
|
||||||
|
`POST /api/v1/demo/reset` (Operations Manager only, and only if `DEMO_ALLOW_RESET=true`)
|
||||||
|
clears all MobilityOps tables, reloads the seed with a fresh date shift, re-runs the
|
||||||
|
data-quality scan, and runs a server-side **scenario-integrity check**
|
||||||
|
(`scenario_integrity_report()` in `backend/app/services/demo_manifest.py`) confirming all
|
||||||
|
five named scenarios are actually present and ready — recorded in both the response body
|
||||||
|
and the `demo_reset` audit event's metadata. Reset only ever affects MobilityOps's own
|
||||||
|
tables; it never touches shared n8n, RAGcore, or MCP data, other containers, or volumes.
|
||||||
|
|
||||||
|
## Seed-validation tests
|
||||||
|
|
||||||
|
`backend/tests/test_seed.py` proves, after every reset:
|
||||||
|
|
||||||
|
- S1 (`BK-DEMO-RETURN`/`MO-024`) is active with no end odometer recorded yet.
|
||||||
|
- S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`) is open with matching evidence.
|
||||||
|
- S4 (`MO-016`/`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) genuinely overlaps in time.
|
||||||
|
- S5 (the seeded failed outbox event) is durably `failed` immediately after reset, not
|
||||||
|
silently auto-healed by the background dispatcher (which only claims `pending` rows).
|
||||||
|
- The date-anchoring shift and the `demo_data_seeded` audit marker are both correct.
|
||||||
|
|
||||||
|
`backend/tests/test_demo_manifest.py` additionally proves that all five manifest
|
||||||
|
scenarios report `ready: true` with no `blocked_reason` right after a fresh reset.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# The in-app Demo Guide
|
||||||
|
|
||||||
|
## What it is (and isn't)
|
||||||
|
|
||||||
|
The Demo Guide is a compact, reusable side panel (a bottom sheet on mobile) that walks an
|
||||||
|
Operations Manager through eight fixed steps covering the demo's core functionality. It is
|
||||||
|
**not** a generic tutorial engine and **not** a source of business logic — every action it
|
||||||
|
prompts is a real action against the real API; the guide only narrates, links, and tracks
|
||||||
|
progress. It duplicates no business logic: routes for the two scenario-backed steps
|
||||||
|
(return, duplicate-merge) are resolved from the live `GET /api/v1/demo/manifest` response
|
||||||
|
rather than hardcoded, so they can never point at a stale or missing record.
|
||||||
|
|
||||||
|
## Where it lives
|
||||||
|
|
||||||
|
- `frontend/src/data/demoGuideSteps.ts` — the eight step definitions (title, what you'll
|
||||||
|
see, why it matters, the exact start action, the expected outcome, and a `route()`
|
||||||
|
function).
|
||||||
|
- `frontend/src/context/DemoGuideContext.tsx` — open/close state and step progress,
|
||||||
|
persisted to `sessionStorage` only (browser-local, never touches auth or business
|
||||||
|
state; a "Demo opnieuw voorbereiden" restart or a new browser session starts fresh).
|
||||||
|
- `frontend/src/components/DemoGuide.tsx` — the panel itself and its topbar trigger
|
||||||
|
(`DemoGuideTrigger`, Operations-Manager-only, since all eight steps require that role).
|
||||||
|
|
||||||
|
## The eight steps
|
||||||
|
|
||||||
|
1. **Understand the operational state** — the dashboard's readiness and attention queue.
|
||||||
|
2. **Open the booking needing attention** — `BK-DEMO-RETURN`, resolved from the manifest.
|
||||||
|
3. **Process the return with an odometer anomaly** — same booking; the return form is
|
||||||
|
pre-filled with the suspicious reading (see [`demo-scenarios.md`](demo-scenarios.md)
|
||||||
|
scenario 1).
|
||||||
|
4. **Handle the created data-quality issue** — the fresh odometer-regression issue that
|
||||||
|
step 3 just created.
|
||||||
|
5. **Review and merge the possible duplicate customer** — `DQ-DEMO-DUPLICATE`.
|
||||||
|
6. **Ask the procedure assistant a question** — one of the suggested questions.
|
||||||
|
7. **Check automation and the audit trail**.
|
||||||
|
8. **Review what's real, simulated, or not yet connected** — the About page.
|
||||||
|
|
||||||
|
## How progression works
|
||||||
|
|
||||||
|
- **"Ga naar deze stap"** navigates to the step's resolved route without marking it done.
|
||||||
|
- **"Volgende"** marks the current step complete and advances the index (used for steps
|
||||||
|
with no dedicated in-page continuation, like the knowledge-assistant step).
|
||||||
|
- Several real actions (a successful return, a resolved data-quality issue) show their
|
||||||
|
own **"Ga verder met de demo"** button that both completes the current step and
|
||||||
|
navigates straight to the next one — this is how the guide chains through steps 3→4→5
|
||||||
|
without a detour back through the panel's own controls.
|
||||||
|
- The step list on the panel lets you jump directly to any step.
|
||||||
|
- **"Demo opnieuw voorbereiden"** calls the real reset endpoint, resets the guide's own
|
||||||
|
progress, and returns to the login screen — mirroring the existing sidebar reset
|
||||||
|
control, not a separate implementation.
|
||||||
|
|
||||||
|
## A known, deliberate limitation: suggested questions stay in English
|
||||||
|
|
||||||
|
The demo knowledge base's five procedure documents (`knowledge/procedures/*.md`) are
|
||||||
|
written in English. Verified empirically while building step 6: an equivalent Dutch
|
||||||
|
question returned `insufficient` evidence against the demo provider, while the English
|
||||||
|
original returned `grounded`. Rather than mistranslate the demo's centerpiece "grounded
|
||||||
|
answer" feature into something that silently answers incorrectly, the suggested questions
|
||||||
|
on `/knowledge` and this guide's step 6 instructions stay in English, with the guide
|
||||||
|
explicitly explaining why in Dutch. Retranslating the procedure documents themselves was
|
||||||
|
judged out of scope for a demo-productization pass (see the language-split note in
|
||||||
|
`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||||
|
|
||||||
|
## Layout note: the panel reserves space, it doesn't overlap content
|
||||||
|
|
||||||
|
On desktop the panel is a fixed 400px-wide right-side overlay. `Layout.tsx` adds a
|
||||||
|
`guide-open` class to the main workspace while the guide is open, which reserves
|
||||||
|
`padding-right` so page content reflows aside instead of sitting underneath the panel —
|
||||||
|
this was a real bug found and fixed while building the full guided-demo Playwright test
|
||||||
|
(see `PROJECT_STATE.md`, Batch 6): without it, the return form's "Review return" button
|
||||||
|
was unclickable while the guide was open at ordinary desktop widths.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Demo runbook
|
||||||
|
|
||||||
|
## Starting the demo (any environment)
|
||||||
|
|
||||||
|
Open the deployed URL (Unraid review: `http://192.168.10.150:1236`; local:
|
||||||
|
`http://localhost:1228`). The login screen names the fictional organisation, states that
|
||||||
|
all data is synthetic and all workflows are really implemented, and offers:
|
||||||
|
|
||||||
|
- **Start begeleide demo** — logs in as Operations Manager and opens the Demo Guide at
|
||||||
|
step 1.
|
||||||
|
- **Verken als Operations Manager** / **Verken als Rental Employee** — free exploration,
|
||||||
|
no guide.
|
||||||
|
|
||||||
|
No password is shown or required for either path.
|
||||||
|
|
||||||
|
## Five-minute demo (guided)
|
||||||
|
|
||||||
|
1. Click **Start begeleide demo**.
|
||||||
|
2. Follow steps 1–3: dashboard → the booking needing attention → confirm the pre-filled
|
||||||
|
odometer-anomaly return.
|
||||||
|
3. Step 4: resolve the data-quality issue the return just created (any decision is fine
|
||||||
|
for a quick pass — "Retain canonical" is the fastest).
|
||||||
|
4. Step 6: ask a suggested knowledge question and show the cited source.
|
||||||
|
5. Step 7: show the automation/audit trail link-through.
|
||||||
|
6. Close with step 8, the About page's honest real/synthetic/not-connected breakdown.
|
||||||
|
|
||||||
|
## Ten-minute demo (guided + one extra scenario)
|
||||||
|
|
||||||
|
Do the five-minute path above, then from `/scenarios`:
|
||||||
|
|
||||||
|
- Run **scenario 2** (duplicate customer merge) if not already done via the guide's own
|
||||||
|
step 5.
|
||||||
|
- Run **scenario 3** (booking overlap) — `/data-quality`, resolve `DQ-DEMO-OVERLAP`.
|
||||||
|
- Run **scenario 4** (failed automation retry) — `/automation`, filter to failed, retry.
|
||||||
|
|
||||||
|
All five scenarios can be run in any order and are independent of each other.
|
||||||
|
|
||||||
|
## Resetting the environment
|
||||||
|
|
||||||
|
Any Operations Manager can reset from: the sidebar ("Reset demo data"), the Demo Guide
|
||||||
|
panel ("Demo opnieuw voorbereiden"), or the About page (points to the sidebar control).
|
||||||
|
Reset requires confirmation, rebuilds the deterministic dataset with a fresh date anchor,
|
||||||
|
runs a server-side scenario-integrity check, and signs the acting session out (the server
|
||||||
|
invalidates the session as part of reset). It only ever touches MobilityOps's own tables
|
||||||
|
— never shared n8n, RAGcore, or MCP data, other containers, or volumes. It can be
|
||||||
|
disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be rebuildable.
|
||||||
|
|
||||||
|
## Redeploying to Unraid
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From a clean local checkout on the target branch/commit:
|
||||||
|
git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD
|
||||||
|
scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source-<short-sha>.tar.gz
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||||
|
&& tar -xzf .deploy/source-<short-sha>.tar.gz \
|
||||||
|
&& echo <full-sha> > .deploy/source-revision"
|
||||||
|
|
||||||
|
# Rebuild only what changed (api and/or web); db is never rebuilt:
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||||
|
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web"
|
||||||
|
|
||||||
|
# Confirm migrations and reseed:
|
||||||
|
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||||
|
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m alembic current \
|
||||||
|
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n
|
||||||
|
data) — the tarball never contains `.env` since it's gitignored. Never edit source
|
||||||
|
directly on the server; never deploy uncommitted changes.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`.deploy/source-revision` on the server records exactly which commit is live. Prior
|
||||||
|
source tarballs remain in `.deploy/` for rollback: extract an earlier
|
||||||
|
`source-<short-sha>.tar.gz`, update `source-revision` to match, and re-run the rebuild
|
||||||
|
step above. Database rollback is out of scope for this demo (migrations are additive;
|
||||||
|
there has been no destructive migration on this branch).
|
||||||
|
|
||||||
|
## Server safety (Unraid)
|
||||||
|
|
||||||
|
Only touch the `mobilityops` Compose project's own `api`/`web` services (and `db` only
|
||||||
|
via migrations, never manually). Never stop other containers, run `docker system prune`,
|
||||||
|
delete unrecognised images/networks/volumes, delete the MobilityOps database, overwrite
|
||||||
|
the server `.env`, print secrets, start a second permanent n8n instance, or activate
|
||||||
|
guessed RAGcore/MCP URLs. PostgreSQL is never exposed externally.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **A scenario shows "Niet beschikbaar" on `/scenarios`**: it has already been resolved
|
||||||
|
since the last reset (expected once you've worked through it) — reset to restore it.
|
||||||
|
- **Knowledge question returns "insufficient evidence"**: check the question is in
|
||||||
|
English and close to one of the suggested phrasings — the demo knowledge base is
|
||||||
|
English-only (see `demo-guide.md`).
|
||||||
|
- **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s
|
||||||
|
filter and retry — bounded retries mean it should self-heal within
|
||||||
|
`n8n_max_attempts` attempts, or can be retried manually by an Operations Manager.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Demo scenarios
|
||||||
|
|
||||||
|
Five fixed scenarios are surfaced at `/scenarios` and via `GET /api/v1/demo/manifest`'s
|
||||||
|
`scenarios` array, which derives each scenario's `ready`/`blocked_reason` from the actual
|
||||||
|
underlying records — never hardcoded. All five are re-findable, using the same public
|
||||||
|
references, after every demo reset (see [`demo-data.md`](demo-data.md)).
|
||||||
|
|
||||||
|
## 1 — Odometer regression on return
|
||||||
|
|
||||||
|
- **Fixed records**: booking `BK-DEMO-RETURN` on vehicle `MO-024`.
|
||||||
|
- **Role**: Rental Employee or Operations Manager.
|
||||||
|
- **Problem demonstrated**: a return is submitted with an odometer reading lower than the
|
||||||
|
vehicle's canonical reading — usually a data-entry mistake or a return logged against
|
||||||
|
the wrong vehicle.
|
||||||
|
- **Flow**: open the booking. The return form is pre-filled with a suspicious reading
|
||||||
|
below `MO-024`'s canonical odometer (the demo never asks a visitor to invent one), with
|
||||||
|
a callout explaining why. Review the server-evaluated preview, then confirm. The
|
||||||
|
canonical odometer is never silently lowered; a data-quality issue is opened
|
||||||
|
automatically and the outcome is queued for delivery to n8n.
|
||||||
|
- **Continue**: the result panel links to the vehicle, automation status, and audit
|
||||||
|
trail, plus (inside the guided tour) a "Ga verder met de demo" button.
|
||||||
|
|
||||||
|
## 2 — Possible duplicate customer
|
||||||
|
|
||||||
|
- **Fixed records**: customers `CUS-0012` and `CUS-0178` (same email/phone), issue
|
||||||
|
`DQ-DEMO-DUPLICATE`.
|
||||||
|
- **Role**: Operations Manager (merge is a bounded, audited action).
|
||||||
|
- **Problem demonstrated**: two customer profiles that are very likely the same person,
|
||||||
|
registered twice — a risk for split booking history and duplicate contact.
|
||||||
|
- **Flow**: open the issue, compare both profiles field by field, choose which survives,
|
||||||
|
and merge. The losing profile becomes a tombstone linked to the survivor; its bookings
|
||||||
|
are rewired. The result is recorded in the audit trail.
|
||||||
|
|
||||||
|
## 3 — Overlapping bookings
|
||||||
|
|
||||||
|
- **Fixed records**: vehicle `MO-016`, bookings `BK-DEMO-OVERLAP-A`/`BK-DEMO-OVERLAP-B`,
|
||||||
|
issue `DQ-DEMO-OVERLAP`.
|
||||||
|
- **Role**: Operations Manager.
|
||||||
|
- **Problem demonstrated**: the same vehicle committed to two overlapping reservations —
|
||||||
|
a legacy-import-style conflict that a normal booking command would reject outright.
|
||||||
|
- **Flow**: open the issue, choose which of the two bookings to block; the other keeps
|
||||||
|
its current status.
|
||||||
|
|
||||||
|
## 4 — Failed automation, retried
|
||||||
|
|
||||||
|
- **Fixed record**: a seeded outbox event (`00000000-0000-4000-8000-000000000020`,
|
||||||
|
`BK-H-0020`) that is durably `failed` immediately after every reset — a safe, simulated
|
||||||
|
connection error, not a deliberately destabilised n8n configuration.
|
||||||
|
- **Role**: Operations Manager.
|
||||||
|
- **Problem demonstrated**: bounded retries and visible failure/recovery state for
|
||||||
|
workflow delivery, rather than a silent drop.
|
||||||
|
- **Flow**: open Automation, filter to failed deliveries, retry the event; it moves out
|
||||||
|
of the failed filter once delivered.
|
||||||
|
|
||||||
|
## 5 — Grounded procedure question
|
||||||
|
|
||||||
|
- **Role**: Rental Employee or Operations Manager.
|
||||||
|
- **Problem demonstrated**: an operational question gets an answer with a citation from
|
||||||
|
the demo knowledge base — or an honest "insufficient evidence" if nothing indexed
|
||||||
|
answers it — never an invented answer.
|
||||||
|
- **Flow**: open Knowledge, click one of the suggested questions (English, matching the
|
||||||
|
indexed procedure content — see the note in [`demo-guide.md`](demo-guide.md) about why
|
||||||
|
suggested questions stay in English even though the rest of the demo narrates in
|
||||||
|
Dutch), and inspect the cited source and evidence state.
|
||||||
|
|
||||||
|
## A sixth scenario exists but isn't one of the five requested
|
||||||
|
|
||||||
|
`docs/13-seed-and-demo-scenarios.md`'s **S3** ("missing inspection before next booking",
|
||||||
|
vehicle `MO-031`) is also seeded and shows up in the attention queue; it isn't surfaced
|
||||||
|
on `/scenarios` because the brief names five specific scenarios, but it remains available
|
||||||
|
for anyone exploring the attention queue directly.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Final product polish — audit
|
||||||
|
|
||||||
|
Performed 2026-08-03 against `feat/mobilityops-functional-completion`.
|
||||||
|
|
||||||
|
## Commit state
|
||||||
|
|
||||||
|
- **Local/branch HEAD**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8`
|
||||||
|
- **Deployed on Unraid** (`/mnt/user/appdata/mobilityops/.deploy/source-revision`):
|
||||||
|
`4a268c73515dc4f1d56c1aa2f231714654bffbb8` — **matches exactly**, no drift.
|
||||||
|
- **Repository's primary branch is `master`, not `main`** — no branch named `main` exists
|
||||||
|
(`git branch -a` / `git remote show` confirm only `master`, `design/mobilityops-premium-ui`,
|
||||||
|
and `feat/mobilityops-functional-completion`). `origin/master` = local `master` =
|
||||||
|
`e0c7ed60112510687627d20a957af91c8b9db7f8`, unchanged since the functional-completion
|
||||||
|
baseline — this is the baseline to compare against before any future merge.
|
||||||
|
- **Evidence-file mismatch found and corrected**: `artifacts/demo-release/final-summary.md`
|
||||||
|
recorded final commit `294a8176d19...` (one commit behind actual HEAD, because its own
|
||||||
|
"record the hash" follow-up commit necessarily couldn't self-reference). Corrected to
|
||||||
|
the verified, unambiguous `4a268c73515dc4f1d56c1aa2f231714654bffbb8`, cross-checked
|
||||||
|
against both `git rev-parse HEAD` and the server's `source-revision`.
|
||||||
|
- Containers on Unraid: `api`/`db`/`web` all healthy; migrations at `e7b08389f47f (head)`.
|
||||||
|
|
||||||
|
## Remaining "MobilityOps" mentions (user-facing)
|
||||||
|
|
||||||
|
- `frontend/index.html:7` — `<title>MobilityOps</title>`; `:6` meta description.
|
||||||
|
- `frontend/src/components/Layout.tsx:178` — sidebar brand lockup `<strong>MobilityOps</strong>`.
|
||||||
|
- `frontend/src/components/Layout.tsx:232` — global search `aria-label`/visually-hidden
|
||||||
|
label "Search MobilityOps".
|
||||||
|
- `frontend/src/components/Layout.tsx:299` — footer `<span>MobilityOps PoC</span>` (also
|
||||||
|
the "PoC" mention to remove).
|
||||||
|
- `frontend/src/pages/Login.tsx:27` — the manifest-fallback org description string.
|
||||||
|
- `frontend/src/pages/AboutDemo.tsx:32,63,71` — page heading and two body paragraphs.
|
||||||
|
- `frontend/src/pages/Knowledge.tsx:108` — empty-state copy.
|
||||||
|
- `frontend/src/pages/BookingDetail.tsx:80` — scenario callout copy.
|
||||||
|
- `frontend/src/data/demoGuideSteps.ts:91` — step 8's expected-outcome text.
|
||||||
|
- `backend/app/services/demo_manifest.py:24` — `ORGANIZATION_DESCRIPTION` (the string
|
||||||
|
`Login.tsx:27` falls back to when the manifest hasn't loaded yet — both need updating
|
||||||
|
together to avoid a flash of stale branding).
|
||||||
|
- `backend/app/main.py:36` — FastAPI `title="MobilityOps API"` (visible in the OpenAPI/
|
||||||
|
Swagger UI a reviewer might open; low-risk to rename since it's a display string, not a
|
||||||
|
route or contract field).
|
||||||
|
- **`knowledge/procedures/*.md` — user-facing, appears directly in cited answer text**:
|
||||||
|
`02-vehicle-return.md`, `03-damage-handling.md` (also says "PoC"), `05-cleaning-
|
||||||
|
checklist.md`, `07-customer-documents.md` (also says "PoC"), `09-booking-conflicts.md`
|
||||||
|
all say "MobilityOps" in prose that gets quoted verbatim as a knowledge-assistant
|
||||||
|
answer excerpt — these must be rebranded too, not just the app chrome.
|
||||||
|
- e2e tests asserting on the string "MobilityOps" that must be updated alongside the
|
||||||
|
rename (not gaps, just dependencies): `demo-accessibility.spec.ts`, `demo-entry.spec.ts`,
|
||||||
|
`guided-demo-full.spec.ts` (About-page heading), `ui-redesign.spec.ts` (4× "Search
|
||||||
|
MobilityOps" combobox name).
|
||||||
|
|
||||||
|
**Kept as technical identifiers** (per the brief, not renamed): Git repo `Jens/MobilityOps`,
|
||||||
|
local repo folder name, Compose project `mobilityops`, deployment dir
|
||||||
|
`/mnt/user/appdata/mobilityops`, Postgres db/user `mobilityops`, `knowledge/manifest.json`'s
|
||||||
|
`"workspace": "mobilityops"` (a RAGcore-workspace config value), frontend package name
|
||||||
|
`mobilityops-web`, and the internal API route prefix conventions.
|
||||||
|
|
||||||
|
## Language problems (no i18n exists at all)
|
||||||
|
|
||||||
|
- `frontend/package.json` has no i18n library (`i18next`/`react-i18next`/etc. absent).
|
||||||
|
- There is no language switcher anywhere and no persisted language preference.
|
||||||
|
- The existing UI mixes English (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||||
|
Audit, Automation internals — all shipped in Task-B's demo-productization pass) and
|
||||||
|
Dutch (Login, Demo Guide, Scenarios, About, demo badge — added in the same pass) by a
|
||||||
|
documented, deliberate scope decision at the time. That decision must now be superseded:
|
||||||
|
the brief requires one true default (nl-BE) with working en-GB/fr-BE switching across
|
||||||
|
the **entire** application, so the English-language existing screens are now in scope
|
||||||
|
for translation, not just new surfaces.
|
||||||
|
- The demo knowledge base (`knowledge/procedures/*.md`) is English-only; a Dutch or French
|
||||||
|
question against it returns `insufficient` evidence (verified empirically in the prior
|
||||||
|
work) — this is a real gap for the trilingual guided demo requirement.
|
||||||
|
|
||||||
|
## Remaining technical user-facing language
|
||||||
|
|
||||||
|
- `frontend/src/pages/Automation.tsx:183` — `{r.event_type}` rendered raw (e.g.
|
||||||
|
`vehicle.returned.v1`) in the primary ledger table, no human label.
|
||||||
|
- `frontend/src/pages/Automation.tsx:91` — "delivered through the outbox" in primary copy.
|
||||||
|
- `frontend/src/pages/Audit.tsx:129` — `{e.correlation_id.slice(0, 8)}` shown as the
|
||||||
|
visible `<summary>` trigger text for technical detail, not a meaningful reference.
|
||||||
|
- `frontend/src/pages/DataQualityIssueDetail.tsx:341,366` — "canonical odometer" used
|
||||||
|
directly in primary decision copy, not translated to an operational phrase.
|
||||||
|
- No central technical→human terminology mapping exists anywhere in the frontend.
|
||||||
|
|
||||||
|
## Demo Guide panel problems
|
||||||
|
|
||||||
|
- `frontend/src/components/DemoGuide.tsx` + the `@media (min-width: 701px)` rule in
|
||||||
|
`styles.css` implement exactly **one** behaviour for every desktop/tablet width ≥701px:
|
||||||
|
a fixed 400px right-side panel with `.app-workspace.guide-open { padding-right:
|
||||||
|
min(400px, 92vw) }` reserving space. There is no distinction between "extra-wide
|
||||||
|
desktop, dock + reflow with a guaranteed minimum content width" and "standard desktop/
|
||||||
|
tablet, floating non-modal sheet that auto-collapses to a progress chip" as the brief
|
||||||
|
now requires — today the panel never collapses to a chip at all; it only fully opens
|
||||||
|
or fully closes.
|
||||||
|
- "Ga naar deze stap" (`DemoGuide.tsx`'s `goToStepRoute()`) only calls `navigate(...)`; it
|
||||||
|
does not scroll to or focus the relevant target element, and does not auto-collapse the
|
||||||
|
panel afterward.
|
||||||
|
- No semantic target anchors exist on pages for the guide to scroll/focus toward.
|
||||||
|
- Reduced-motion is not explicitly handled for the panel's open/close or any future
|
||||||
|
collapse/expand transition.
|
||||||
|
|
||||||
|
## Data Quality Workbench contrast and hierarchy
|
||||||
|
|
||||||
|
- Confirmed by direct inspection of `DataQualityIssueDetail.tsx`: only the odometer-
|
||||||
|
regression decision (lines 349,358) uses the higher-contrast `.check-card` treatment
|
||||||
|
(`background: var(--surface-subtle); border: 1px solid var(--line); padding: 10px 12px`).
|
||||||
|
Every other decision point — `DuplicateCustomerPanel`'s survivor choice (134,143) and
|
||||||
|
per-field merge choice (172,187), and `BookingOverlapPanel`'s block-choice (459) — uses
|
||||||
|
the bare `.checkbox-label` class: a small inline radio with no card background, no
|
||||||
|
border, no selected-state treatment, and no visible risk/consequence copy next to the
|
||||||
|
option. This is the inconsistency the brief describes as "kleine losse radioknoppen in
|
||||||
|
een zwak omlijnde rij."
|
||||||
|
- Resolve/defer/reject already have some visual differentiation (`button-primary` for the
|
||||||
|
main resolution action inside each panel; a separate "Defer or reject" section below),
|
||||||
|
but defer and reject are rendered as two identically-weighted plain `<button>` elements
|
||||||
|
with no secondary/tertiary visual distinction from each other.
|
||||||
|
- No sticky/always-visible action bar exists within longer resolution panels.
|
||||||
|
|
||||||
|
## Automation/audit density
|
||||||
|
|
||||||
|
- `frontend/src/pages/Automation.tsx`'s delivery ledger renders every one of the ~20
|
||||||
|
seeded `succeeded` events as an individual flat table row with only a status-text
|
||||||
|
filter (`pending`/`delivering`/`succeeded`/`failed`) — no grouping/summarization of
|
||||||
|
repeated successes, and event references are raw 8-character UUID slices with no
|
||||||
|
meaningful short display reference scheme.
|
||||||
|
- `frontend/src/pages/Audit.tsx` shows one row per raw audit event; my prior "View related
|
||||||
|
events" filter (added in the demo-productization pass) lets a user filter down to a
|
||||||
|
correlation group, but does not present that group as one collapsed operational summary
|
||||||
|
by default — a visitor still sees N separate technical rows even after filtering.
|
||||||
|
- Before/after is already shown as a computed diff string (`describeChanges()`), not raw
|
||||||
|
JSON, which partially satisfies 8C already — but the diff is a flat semicolon-joined
|
||||||
|
string, not the labelled multi-line "Status: A → B" presentation the brief shows.
|
||||||
|
|
||||||
|
## Rows that are not fully clickable
|
||||||
|
|
||||||
|
- **Attention Queue** (`Dashboard.tsx:126-146`, `.attention-list li`): only the `<Link>`
|
||||||
|
wrapping the item's title text is interactive; the severity badge, detail text, ref,
|
||||||
|
and chevron are inert. Confirmed by direct markup inspection.
|
||||||
|
- **Today's movements** (`Dashboard.tsx:153-161`, `.movement-timeline li`): only the
|
||||||
|
`<Link>` wrapping the booking reference is interactive; each row unambiguously points
|
||||||
|
to one booking, so this qualifies for the same fully-clickable-row treatment.
|
||||||
|
- **Recent activity** (`Dashboard.tsx:220-224`, `.recent-list li`): has **no** link at all
|
||||||
|
today, and there is no existing single-record detail page for an individual automation
|
||||||
|
event to point to — out of scope for "make it clickable" per the brief's own
|
||||||
|
"alleen wanneer de volledige rij ondubbelzinnig naar één bestemming verwijst" carve-out;
|
||||||
|
left as-is unless a natural destination is introduced elsewhere in this pass.
|
||||||
|
- Scenario cards (`Scenarios.tsx`) already use a single full-card "Start scenario" link
|
||||||
|
per card with no competing interactive elements inside — already compliant, no change
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## Plan (implementation order)
|
||||||
|
|
||||||
|
1. Rebrand to Fleet Ops (frontend strings, backend description strings, knowledge
|
||||||
|
procedure prose, index.html, e2e assertions) — batch, tested, committed.
|
||||||
|
2. i18n architecture: add `i18next`/`react-i18next`, language switcher, persistence,
|
||||||
|
`html lang`, `Intl` formatting, namespaces, fail-fast missing-key check.
|
||||||
|
3. Translate all existing screens + new demo surfaces into nl-BE/en-GB/fr-BE.
|
||||||
|
4. Backend message-code fields for data-quality reasons/recommended actions/return
|
||||||
|
reasons/integration statuses/audit actions/errors; frontend localizes.
|
||||||
|
5. Multilingual knowledge base (5 procedures × 3 locales) + locale-aware provider lookup.
|
||||||
|
6. Adaptive Demo Guide: docked-rail / floating-sheet+chip / mobile-bottom-sheet behaviour,
|
||||||
|
scroll-to-target + focus + highlight, reduced motion.
|
||||||
|
7. Data Quality Workbench: choice cards everywhere, sticky action bar, de-emphasized
|
||||||
|
defer/reject, contrast fixes.
|
||||||
|
8. Central terminology mapping layer (nl/en/fr) used in Automation/Audit/Data-Quality.
|
||||||
|
9. Automation ledger grouping/filtering + retry UX narration.
|
||||||
|
10. Audit trail human action labels + correlation-grouped summary + labelled diffs.
|
||||||
|
11. Fully clickable Attention Queue + Today's movements rows, with tests.
|
||||||
|
12. Responsive pass across the 7 required breakpoints × 3 languages.
|
||||||
|
13. Full test suite additions (i18n, branding, guide, quality, queue, automation/audit,
|
||||||
|
trilingual guided demo).
|
||||||
|
14. Clean-checkout drill.
|
||||||
|
15. Deploy feature branch to Unraid for final validation.
|
||||||
|
16. Safe merge to `master` (the repository's actual main branch) + redeploy + final
|
||||||
|
evidence at `artifacts/fleet-ops-release/final-summary.md`.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Fleet Ops correction — current-state gap audit
|
||||||
|
|
||||||
|
## Branch / commit state (at audit time)
|
||||||
|
|
||||||
|
- Repository's actual main/default branch is named **`master`** (there is no `main` branch — `remotes/origin/HEAD -> origin/master`). All instructions referring to "main" in this task are treated as referring to `master`.
|
||||||
|
- `master` (local and `origin/master`) was at `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` before this task started — this is the newest verified Fleet Ops demo commit (contains the full rebrand/i18n/adaptive-guide/Data-Quality-UX work from the previous task).
|
||||||
|
- Deployed commit on Unraid (`/mnt/user/appdata/mobilityops/.deploy/source-revision`): `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` — matches `master` exactly. No drift.
|
||||||
|
- No uncommitted local changes at audit time (`git status` clean).
|
||||||
|
- Source branch for this correction: `master` (already contained the newest verified commit). New branch created: **`fix/fleet-ops-i18n-status-flow`**, branched from `master` at `18344bc`.
|
||||||
|
- `feat/mobilityops-functional-completion` remains un-deleted, as instructed by the prior task, and is left untouched by this one.
|
||||||
|
|
||||||
|
## Confirmed gaps (verified against actual code, not assumed)
|
||||||
|
|
||||||
|
### 1. Brand name is a translatable key (structural risk)
|
||||||
|
|
||||||
|
- `common:appName` exists per-locale in `frontend/src/i18n/locales/{nl-BE,en-GB,fr-BE}/common.json`, all currently `"Fleet Ops"`, but nothing prevents a future edit from diverging one locale. Used in `Login.tsx:35` and `Layout.tsx:180`.
|
||||||
|
- 8 more locale keys embed the literal string "Fleet Ops" inside translatable prose (`auth.defaultDescription`, `common.footer.productLine`, `demo.guide.steps["review-real-vs-simulated"].expectedOutcome`, `demo.about.title`, `demo.about.problemBody`, `demo.about.scopeBody`, `knowledge.emptyDescription`, `navigation.searchLabel`, `returns.scenario.body`) — all three locales currently say "Fleet Ops" correctly, but structurally these are still translatable values.
|
||||||
|
- **Fix**: `frontend/src/product.ts` exports `PRODUCT_NAME = "Fleet Ops"`. Remove `common:appName`; `Login.tsx`/`Layout.tsx` import the constant directly. Replace the 8 embedded mentions with `{{productName}}` interpolation, passing `productName: PRODUCT_NAME` explicitly at each call site. Add a Playwright test that fails if any locale JSON file contains the literal substring `"Fleet Ops"` (forcing all future brand mentions through interpolation) and a live-DOM test asserting the rendered brand text is byte-identical across all three languages.
|
||||||
|
|
||||||
|
### 2. Backend hardcodes English prose as primary user-facing content (structural, not cosmetic)
|
||||||
|
|
||||||
|
Confirmed in `backend/app/services/data_quality.py`:
|
||||||
|
- `_scan_duplicate_customers` (~line 127-149): evidence signals literally `"exact email"`, `"exact phone"`, `"exact postal code"`, `"similar name"`, joined into `evidence.summary`.
|
||||||
|
- `_scan_missing_required_fields` (~170, 185): `f"Missing: {', '.join(missing)}"`.
|
||||||
|
- `_scan_booking_overlaps` (~213): `f"Overlapping bookings {first.public_ref} and {second.public_ref}"`.
|
||||||
|
- `_scan_vehicle_status_conflicts` (~240-256): reason strings like `"marked available while an active booking exists"`.
|
||||||
|
- `_scan_odometer_regressions` (~291-295): full English sentence with interpolated numbers/refs.
|
||||||
|
- `_recommend_vehicle_status` (~659-668): recommendation `reason` strings returned verbatim as `ApplyRecommendedStatusResult.reason` and rendered directly in the UI.
|
||||||
|
|
||||||
|
Confirmed in `backend/app/services/returns.py`:
|
||||||
|
- `_derive_vehicle_status_with_reason` (~32-43): `status_reason` strings ("Damage was reported on return.", "A technical warning was reported on return.", "Odometer reached the {n} km service threshold.", "No damage, technical warning or service threshold; routed to cleaning.") flow straight into the API response and are displayed raw regardless of UI language (asserted verbatim in English in `interactive-elements.spec.ts:133`, confirming this is genuinely user-visible, not just internal).
|
||||||
|
|
||||||
|
Confirmed in `backend/app/services/dispatcher.py` / seed data: `event.last_error` stores raw strings like `"Synthetic connection timeout to n8n"` from `seed/workflow_runs.csv`, rendered directly in `Automation.tsx` (`{r.last_error ?? "—"}`) with no localization or summarization.
|
||||||
|
|
||||||
|
Frontend confirmed to display these values completely raw: `DataQualityIssueDetail.tsx` → `<dd>{String(issue.evidence.summary ?? "")}</dd>` — the *label* is translated, the *value* is not.
|
||||||
|
|
||||||
|
**Fix**: introduce structured `signals`/`message_code`+`params` on evidence and reasons (data-quality evidence, status-conflict recommendation reason, return status reason, automation last-error), with a frontend mapping layer that localizes known codes and falls back to the raw string under "Technical details" only.
|
||||||
|
|
||||||
|
### 3–5, 8A. Status-recommendation flow is unsafe and combines calculation with mutation
|
||||||
|
|
||||||
|
- **Confirmed single mutating endpoint**: `POST /api/v1/data-quality/issues/{public_ref}/apply-recommended-status` (`backend/app/api/routers/data_quality.py`) computes the recommendation and mutates the vehicle in the same call. No separate preview/GET route exists for this flow (unlike the return flow, which already has `preview_vehicle_return` / `register_vehicle_return` sharing one pure evaluator).
|
||||||
|
- **Confirmed unsafe shortcut** (explicitly prohibited by this task): `_recommend_vehicle_status` in `data_quality.py` returns `("rented", ...)` whenever `operational_status == "maintenance" and has_active_booking` — i.e. a vehicle flagged for maintenance with an active booking is auto-recommended (and, on one click, actually changed) to `rented`, with no check of the underlying reason it's in maintenance and no manual-review branch.
|
||||||
|
- **Confirmed proxy-based reasoning** (explicitly prohibited): the `available` + `has_open_high_issue` → `blocked` branch depends only on "does some other open high-severity issue exist for this vehicle", not on real underlying facts (damage, technical warning, confirmed overlap). `data_quality.py` never imports `Inspection`, so damage/technical-warning facts are never examined by this function at all.
|
||||||
|
- **Confirmed scanner/resolver duplication**: `_scan_vehicle_status_conflicts` and `_recommend_vehicle_status` are two independently-maintained `if`-chains (hand-kept-in-sync via a docstring comment, not shared code) — a structural fragility even though today's four branches happen to agree.
|
||||||
|
|
||||||
|
**Fix**: new shared domain service `backend/app/services/vehicle_status.py` with one `evaluate_vehicle_status()` function consulting real facts (active/reserved/overlapping bookings, latest inspection damage/technical-warning, maintenance threshold, cleaning state), used by scanner, a new non-mutating preview endpoint, the apply endpoint, and tests. Maintenance+active-booking becomes `manual_review_required`, never an automatic `rented`. Full decision table in `docs/fleet-ops-correction/vehicle-status-decision-table.md`.
|
||||||
|
|
||||||
|
### 6–7. MO-016 / issue-ordering
|
||||||
|
|
||||||
|
- Confirmed `MO-016` scenario: vehicle seeded `operational_status="available"`, two overlapping *reserved* bookings (`BK-DEMO-OVERLAP-A`/`-B`), two pre-seeded `open` issues on the same vehicle (`DQ-DEMO-OVERLAP` booking_overlap, `DQ-DEMO-STATUS` vehicle_status_conflict).
|
||||||
|
- Confirmed **no test** resolves both issues in sequence (either order) within one session to check the outcome stays deterministic — each existing test independently resets the demo data first.
|
||||||
|
|
||||||
|
**Fix**: new evaluator is order-independent by construction (recomputes real facts every call, doesn't cache any prior issue's existence as an input other than the generic "another open high-severity issue for manual-review fallback"); add an explicit ordering test.
|
||||||
|
|
||||||
|
### 8. i18n-coverage test doesn't prove translation happened
|
||||||
|
|
||||||
|
Confirmed: `frontend/e2e/i18n-coverage.spec.ts` only checks key-parity and non-empty values — a locale file could contain the literal English string copy-pasted and the test would still pass. Locale files were manually verified as genuinely translated (no hits for probe phrases like "canonical odometer", "committed locally", "correlation ID" etc. in `nl-BE`/`fr-BE`), so this is a test-coverage gap, not an active mistranslation — but per this task's instructions it still needs closing.
|
||||||
|
|
||||||
|
**Fix**: add a translation-quality test comparing `nl-BE`/`fr-BE` values against `en-GB` for meaningful divergence (with an explicit allowlist for real proper nouns/technical tokens: Fleet Ops, Northstar Mobility, n8n, RAGcore, MCP Hub, API, UUID, Docker, PostgreSQL), plus a route-matrix smoke test opening every main route in all three languages.
|
||||||
|
|
||||||
|
### 9. One confirmed leftover hardcoded string
|
||||||
|
|
||||||
|
- `frontend/src/pages/BookingDetail.tsx:76` — `aria-label="Demo scenario"` is a literal, un-translated English string (the visible content beside it is correctly translated).
|
||||||
|
|
||||||
|
**Fix**: route through `t("returns:scenario.ariaLabel")` (new key, 3 locales).
|
||||||
|
|
||||||
|
### 10. Automation "last error" shown raw
|
||||||
|
|
||||||
|
- `Automation.tsx` renders `r.last_error` directly with no localization/summarization layer, confirmed via the seeded `"Synthetic connection timeout to n8n"` string appearing verbatim regardless of UI language.
|
||||||
|
|
||||||
|
**Fix**: known-code → localized summary + operational meaning, raw string demoted to "Technical details".
|
||||||
|
|
||||||
|
## Scope note
|
||||||
|
|
||||||
|
No gaps were found in: existing Control Rail navigation/layout, the three demo roles/authorization, the guided demo mechanics, n8n integration wiring, Docker/Unraid deployment scripts, or the previously-implemented adaptive Demo Guide / Data Quality choice-card UI — these are left untouched per the "do not redesign" instruction. This correction is scoped to the 10 problems above.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# i18n inventory — dynamic/backend content requiring message-code treatment
|
||||||
|
|
||||||
|
Static UI chrome (navigation, dashboard, forms, filters, dialogs, empty/loading states,
|
||||||
|
Demo Guide, About page, accessibility labels) was already moved to the `i18next`
|
||||||
|
namespace system in the prior task and is not re-inventoried here in full — see
|
||||||
|
`docs/final-product-polish/audit.md` and `docs/final-product-polish/i18n-inventory.md`
|
||||||
|
for that pass. This inventory covers only the sources confirmed still bypassing
|
||||||
|
translation (per `current-gap-audit.md`), the fix chosen, and the tests that verify it.
|
||||||
|
|
||||||
|
| # | Source | Location | Static/dynamic | Fix | Tests |
|
||||||
|
|---|--------|----------|-----------------|-----|-------|
|
||||||
|
| 1 | Duplicate-customer evidence signals | `backend/app/services/data_quality.py::_scan_duplicate_customers` | dynamic | `evidence.signals: [{code, params}]` (`duplicate.exact_email`, `duplicate.exact_phone`, `duplicate.same_postal_code`, `duplicate.similar_name` + score param); frontend maps code→localized phrase | `test_data_quality.py::test_duplicate_customer_evidence_has_structured_signals`; Playwright DQ evidence-language test |
|
||||||
|
| 2 | Missing-required-field evidence | `_scan_missing_required_fields` | dynamic | `evidence.signals: [{code: "missing_field", params: {field}}]`; frontend maps `field` through the existing `CUSTOMER_FIELD_LABELS`/`VEHICLE_FIELD_LABELS`-equivalent i18n keys | same |
|
||||||
|
| 3 | Booking-overlap evidence | `_scan_booking_overlaps` | dynamic | `evidence.signals: [{code: "overlap.reserved_bookings", params: {refs: [...]}}]` | same |
|
||||||
|
| 4 | Vehicle-status-conflict evidence | `_scan_vehicle_status_conflicts` | dynamic | replaced entirely by the new `evaluate_vehicle_status()` evaluator's `recommendation_code`; scanner reuses the evaluator instead of its own reason strings | new evaluator unit tests |
|
||||||
|
| 5 | Odometer-regression evidence | `_scan_odometer_regressions` | dynamic | `evidence.signals: [{code: "odometer.regression", params: {later_ref, later_km, earlier_ref, earlier_km}}]` | same |
|
||||||
|
| 6 | Status-recommendation reason | `_recommend_vehicle_status` / new `evaluate_vehicle_status` | dynamic | `recommendation_code` + `facts` (structured), no free prose from the backend at all; frontend renders the full explanation from `quality:statusRecommendation.codes.<code>` | evaluator unit tests + preview/apply contract tests |
|
||||||
|
| 7 | Return status reason | `backend/app/services/returns.py::_derive_vehicle_status_with_reason` | dynamic | `status_reason_code` + `params` alongside the existing human string (kept for backward-compat, demoted to technical fallback) | `test_returns.py` updated; Playwright return-flow-language test |
|
||||||
|
| 8 | Automation `last_error` | `dispatcher.py` / seed data, rendered in `Automation.tsx` | dynamic | known-cause codes (`n8n.connection_timeout`, `n8n.http_error`, etc.) mapped to a localized summary + "what happened / what's pending / what retry does"; raw string demoted to Technical details | Playwright automation-language test |
|
||||||
|
| 9 | `aria-label="Demo scenario"` | `frontend/src/pages/BookingDetail.tsx:76` | static, just un-wired | `t("returns:scenario.ariaLabel")`, 3 locales | i18n-coverage (key parity) + route-matrix test |
|
||||||
|
| 10 | Brand name (`common:appName` + 8 embedded mentions) | see gap audit §1 | static, wrongly translatable | `PRODUCT_NAME` constant, `{{productName}}` interpolation | new brand-invariant test |
|
||||||
|
|
||||||
|
## Message-code mapping module
|
||||||
|
|
||||||
|
Centralised in `frontend/src/i18n/messageCodes.ts` (new): a single `resolveMessageCode(t, code, params)` helper used by the Data Quality evidence renderer, the status-recommendation panel, the return-result panel, and the automation ledger, so there is one place mapping `code → i18next key` rather than per-page switch statements.
|
||||||