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 |
@@ -29,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
|
||||||
@@ -37,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`
|
||||||
|
|||||||
@@ -26,16 +26,19 @@ 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.
|
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
||||||
# Same owner-account precondition as n8n-setup above.
|
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
|
||||||
|
# needs "Fleet Ops Service Token").
|
||||||
n8n-setup-scan:
|
n8n-setup-scan:
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
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 exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
docker compose restart n8n
|
docker compose restart n8n
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ M7 — complete. All milestones (M0–M7) done, plus a full post-M7 final-accept
|
|||||||
|
|
||||||
## Locked decisions
|
## Locked decisions
|
||||||
|
|
||||||
- Product name: MobilityOps.
|
- Product name: Fleet Ops (the only visible product name in the UI/copy, never translated;
|
||||||
|
see `frontend/src/product.ts`). "MobilityOps" is the internal repo name, Compose project
|
||||||
|
name and deployment directory only — never shown to a user. See the "Final product
|
||||||
|
polish: Fleet Ops rebrand" and "Fleet Ops final localization" entries below.
|
||||||
- Fictitious tenant: Northstar Mobility Demo.
|
- Fictitious tenant: Northstar Mobility Demo.
|
||||||
- PoC only; all operational and knowledge data are synthetic.
|
- Synthetic demo data only; all operational and knowledge data are synthetic. The product
|
||||||
|
itself is not described as a "PoC" in user-facing copy (see the localization entry
|
||||||
|
below) — this document and other internal/engineering docs may still use "PoC" to
|
||||||
|
describe the engineering scope, per `CLAUDE.md`.
|
||||||
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
|
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
|
||||||
- RAGcore and ITWorx MCP Hub are external central services.
|
- RAGcore and ITWorx MCP Hub are external central services.
|
||||||
- n8n receives post-commit events through an outbox dispatcher.
|
- n8n receives post-commit events through an outbox dispatcher.
|
||||||
@@ -818,3 +824,877 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
|
|||||||
every batch was tested locally, deployed to Unraid, and re-verified live before moving
|
every batch was tested locally, deployed to Unraid, and re-verified live before moving
|
||||||
to the next. See `artifacts/demo-release/final-summary.md` for the definitive
|
to the next. See `artifacts/demo-release/final-summary.md` for the definitive
|
||||||
acceptance evidence.
|
acceptance evidence.
|
||||||
|
|
||||||
|
## Final product polish: Fleet Ops rebrand, trilingual i18n, adaptive guide (2026-08-03) — MERGED TO MASTER
|
||||||
|
|
||||||
|
- Rebranded the product to **Fleet Ops** across the frontend, backend defaults and the
|
||||||
|
knowledge base; made `nl-BE` (default)/`en-GB`/`fr-BE` full first-class languages via
|
||||||
|
i18next (eager-bundled resources, persisted language switcher in topbar + mobile
|
||||||
|
drawer, `Intl` date/number formatting, a coverage test that fails the build on any
|
||||||
|
missing/empty translation key across all 14 namespaces).
|
||||||
|
- Backend dynamic content (demo scenarios, blocked-reason text, integration status)
|
||||||
|
converted from fixed English/Dutch prose to stable message codes + params so the
|
||||||
|
frontend localizes it (`DemoScenarioOut`/`DemoIntegrationSummaryOut` schema changes).
|
||||||
|
The demo knowledge base gained a fully translated NL/EN/FR procedure corpus (11
|
||||||
|
documents each, including a new "vehicle availability" procedure) with per-language
|
||||||
|
retrieval and localized evidence-state messages.
|
||||||
|
- Demo Guide became breakpoint-adaptive: docked rail (≥1440px), a floating panel that
|
||||||
|
auto-collapses to a persistent closable progress chip (701–1439px), and a
|
||||||
|
collapsed/half/full bottom sheet (≤700px) — with scroll+focus+highlight on "go to this
|
||||||
|
step", Escape handling, and `prefers-reduced-motion` support.
|
||||||
|
- Data Quality Workbench got accessible choice-card decisions with a clear
|
||||||
|
primary/secondary/tertiary action hierarchy; Automation ledger groups repeated
|
||||||
|
successes with meaningful short refs; Audit trail groups events by correlation id with
|
||||||
|
human action labels and readable before/after diffs; Attention Queue/Today's
|
||||||
|
movements/Vehicles/Bookings/Data Quality rows are fully clickable (stretched-link
|
||||||
|
pattern, independent secondary links, keyboard + mobile support).
|
||||||
|
- Two real bugs found and fixed along the way: a mobile topbar overflow at 421–440px
|
||||||
|
caused by the new language switcher (moved the switcher into the mobile drawer at
|
||||||
|
≤960px and widened the compact-topbar breakpoint to 440px), and two dangling
|
||||||
|
`aria-labelledby` references (`SectionHeading` never set the referenced `id`).
|
||||||
|
- Full test suite: 131 backend tests, Ruff, mypy, TypeScript build, and 92 Playwright
|
||||||
|
tests (new: `i18n-coverage`, `clickable-rows`, `responsive-i18n` covering all 7
|
||||||
|
brief-specified breakpoints × 3 languages, plus 3 new adaptive-guide tier tests) — all
|
||||||
|
green. All pre-existing Playwright specs updated for the new nl-BE default (either
|
||||||
|
translated assertions or an explicit English-locale override where the spec was
|
||||||
|
originally authored against English copy).
|
||||||
|
- Clean-checkout drill performed in a fully isolated Docker Compose project (separate
|
||||||
|
ports/volumes, no shared n8n) from a fresh local clone at the feature-branch head —
|
||||||
|
131 backend tests, lint, build and all 92 Playwright tests green from empty volumes;
|
||||||
|
live EN/FR knowledge-assistant spot check; `scenario_integrity.all_ready: true` on
|
||||||
|
reset; isolated stack torn down afterward, original dev environment untouched.
|
||||||
|
- Deployed to Unraid twice: once for the feature branch (commit `845db14`) for
|
||||||
|
pre-merge live validation, once for the merged `master` (commit `18a765d`) for the
|
||||||
|
final release — both times via the established `git archive` → `scp` →
|
||||||
|
extract → `.deploy/source-revision` → rebuild `api`/`web` method, with migrations,
|
||||||
|
reseed, full backend+Playwright gates, console/network inspection and demo reset
|
||||||
|
re-verified live each time.
|
||||||
|
- Master baseline was confirmed unchanged (`e0c7ed6`, matching the previously recorded
|
||||||
|
baseline) before merging; `git merge-tree` dry run showed zero conflicts. Merged via
|
||||||
|
`git merge --no-ff` (commit `18a765d`), all gates re-run post-merge, pushed to Gitea,
|
||||||
|
redeployed. Feature branch was not deleted.
|
||||||
|
- Full evidence: `artifacts/fleet-ops-release/final-summary.md` (commits, branding,
|
||||||
|
locales, translation/knowledge-base/guide/data-quality/automation/audit evidence, all
|
||||||
|
test results, clean-checkout result, deployment evidence for both the feature branch
|
||||||
|
and master, responsive/accessibility results, known limitations, rollback procedure)
|
||||||
|
plus 10 screenshots in `artifacts/fleet-ops-release/screenshots/`.
|
||||||
|
|
||||||
|
## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — MERGED TO MASTER
|
||||||
|
|
||||||
|
Branch `fix/fleet-ops-i18n-status-flow`, created from master's post-release head
|
||||||
|
(`18344bc`). Audit and rationale in `docs/fleet-ops-correction/` (gap audit, i18n
|
||||||
|
inventory, vehicle-status decision table). Merged to master via `de0bdea` ("merge:
|
||||||
|
complete Fleet Ops localization and status resolution"), with final evidence commit
|
||||||
|
`f780557` ("docs(release): final Fleet Ops correction evidence and screenshots") —
|
||||||
|
`f780557` is `origin/master`'s current head as of the start of the correction round
|
||||||
|
below.
|
||||||
|
|
||||||
|
- **Status-recommendation flow redesigned** per the brief: 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`) used
|
||||||
|
identically by the scanner, a new non-mutating preview endpoint
|
||||||
|
(`POST .../status-recommendation`), and a transactional apply endpoint
|
||||||
|
(`POST .../apply-recommended-status`) that locks the row, recomputes facts, rejects a
|
||||||
|
stale `recommendation_token` (optimistic concurrency), refuses unsafe/manual-review
|
||||||
|
recommendations, and re-validates post-write before resolving the issue. Frontend
|
||||||
|
`DataQualityIssueDetail.tsx` shows "Review recommendation" → a decision panel
|
||||||
|
(current/recommended status, why, evidence, consequences, localized in all 3
|
||||||
|
languages) → an exact "Change status to <status>" confirm action → result, with a
|
||||||
|
distinct "Manual review required" state offering no generic apply button.
|
||||||
|
- Fixed the real unsafe shortcut this evaluator exists to eliminate: "maintenance +
|
||||||
|
active booking" no longer auto-recommends "rented" (current status is itself now a
|
||||||
|
blocking fact), and "maintenance with nothing else wrong" no longer auto-clears to
|
||||||
|
"available" (no fact proves maintenance is actually finished — that release stays a
|
||||||
|
manual decision).
|
||||||
|
- **MO-016 order independence**: 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 must (and does) hold either way:
|
||||||
|
the recommendation always reflects real current facts, and nothing unsafe is ever
|
||||||
|
applied (never "rented"). Verified by both a backend test
|
||||||
|
(`test_mo_016_status_conflict_recommendation_is_order_independent`, explicitly scoped
|
||||||
|
to MO-016/DQ-DEMO-STATUS after finding the original version wasn't) and a browser-level
|
||||||
|
Playwright test in both orders.
|
||||||
|
- **"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.
|
||||||
|
- **Dynamic backend prose converted to message codes + params**: return status reasons,
|
||||||
|
audit field/actor-type labels, automation `last_error` (new `last_error_code` column,
|
||||||
|
migration `799d8800e241`), and search results (sections/vehicles/bookings/issues) all
|
||||||
|
now carry stable codes the frontend localizes; raw technical text is demoted to a
|
||||||
|
"Technical details" disclosure everywhere.
|
||||||
|
- **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. Also 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 (knowledge
|
||||||
|
prose is visible content, missed by the earlier rebrand).
|
||||||
|
- New `frontend/e2e/fleet-ops-correction.spec.ts` (14 tests) covers branding in 3
|
||||||
|
languages, language persistence, the full status-recommendation flow (non-mutating
|
||||||
|
preview, exact confirm text, manual review, stale-token rejection), MO-016 order
|
||||||
|
independence, trilingual knowledge grounding, and localized audit/automation. Writing
|
||||||
|
it surfaced and fixed two real bugs: the frontend conflated "no conflict" with
|
||||||
|
"manual review required" (both carry `safe_to_apply: false`), and the original
|
||||||
|
MO-016 backend test never actually targeted MO-016's own issue.
|
||||||
|
- Added a keyboard/reduced-motion/no-color-only-status accessibility test for the new
|
||||||
|
status-decision panel; added `aria-live="polite"` to the panel so the applied
|
||||||
|
confirmation is announced.
|
||||||
|
- `contracts/openapi.yaml` and `README.md` updated: title is "Fleet Ops", the new
|
||||||
|
status-recommendation endpoint documented, apply-recommended-status's request body
|
||||||
|
and error codes documented, search endpoint's code+params shape documented, README
|
||||||
|
states the Fleet Ops/MobilityOps naming split explicitly and refreshes stale test
|
||||||
|
counts (151 backend, 108 Playwright).
|
||||||
|
- Gates green: 151 backend tests, Ruff, mypy, Alembic upgrade/downgrade verified,
|
||||||
|
frontend `tsc`/build, full 113-test Playwright suite (rebuilt `api`+`web` containers
|
||||||
|
each time before testing).
|
||||||
|
- Section 11D/E/F of the i18n test-strengthening brief done: a hardcoded-JSX-text
|
||||||
|
static check (`i18n-coverage.spec.ts`; had to anchor on backreferenced closing-tag
|
||||||
|
names — a naive `>text<` scan misread TypeScript generics like
|
||||||
|
`useState<string | null>` as JSX spanning to the next unrelated `>`; verified against
|
||||||
|
both false positives and a deliberately-injected-then-reverted false negative), and a
|
||||||
|
3-language route matrix (`fleet-ops-correction.spec.ts`) covering every main route:
|
||||||
|
no console errors, correct `html[lang]`, real page headings.
|
||||||
|
- **Clean-checkout drill (2026-08-03) — PASS.** Fresh `git clone --branch
|
||||||
|
fix/fleet-ops-i18n-status-flow` of only committed files into an isolated directory,
|
||||||
|
separate Compose project name and host ports (8129/1229/5679) so the working dev
|
||||||
|
stack was never touched. From empty volumes: `docker compose build` + `up -d` →
|
||||||
|
`alembic upgrade head` (lands on `799d8800e241`, the `last_error_code` migration) →
|
||||||
|
`reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27 data-quality issues
|
||||||
|
/ 20 workflow runs — matches the corrected deterministic count) → 151 backend tests +
|
||||||
|
Ruff + mypy green → `npm ci` + frontend build green → full Playwright suite green
|
||||||
|
(113 tests; a few sequential-run-only flakes reproduced from resource contention of
|
||||||
|
running two full Docker stacks at once on one machine — every one confirmed to pass
|
||||||
|
in isolation, none touch code this branch changed) → final reset →
|
||||||
|
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes and images
|
||||||
|
torn down afterward; original dev environment confirmed untouched and reset to
|
||||||
|
baseline.
|
||||||
|
- **Unraid deployment (2026-08-03/04) — PASS.** Pushed `fix/fleet-ops-i18n-status-flow`
|
||||||
|
to origin, deployed via `git archive` → `scp` → extract into
|
||||||
|
`/mnt/user/appdata/mobilityops` (preserving `.env`) → `.deploy/source-revision` →
|
||||||
|
rebuild `api`+`web` → `alembic upgrade head` → reset/reseed, at
|
||||||
|
`http://192.168.10.150:1236`. **Live validation directly caught a real bug**: every
|
||||||
|
data-quality issue's top-of-page evidence summary was unconditionally showing raw
|
||||||
|
English (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. Fixed (commit `2e4fb43`):
|
||||||
|
DataQualityIssueDetail.tsx now renders `evidence.signals` through the operator's
|
||||||
|
locale as the primary text, raw text moved to "Technical details" only, the 4
|
||||||
|
DQ-DEMO-* seed rows got real computed signals (the duplicate-customer similarity
|
||||||
|
score is the actual SequenceMatcher ratio on the seeded names), and a regression
|
||||||
|
test locks this in. Redeployed with the fix; live-verified via `read_page` that
|
||||||
|
DQ-DEMO-STATUS now shows "Dit voertuig heeft twee overlappende reserveringen..."
|
||||||
|
instead of the raw English sentence. Full 116-test Playwright suite green against
|
||||||
|
the live server (`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`), no console
|
||||||
|
errors, no errors in `api`/`web` container logs, both containers healthy, final
|
||||||
|
reset done, `scenario_integrity.all_ready: true`.
|
||||||
|
- Final evidence: `artifacts/fleet-ops-correction/final-summary.md`. Merged to master
|
||||||
|
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
|
||||||
|
final localization" entry below for the next (small correction) round on top of this.
|
||||||
|
|
||||||
|
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — MERGED TO MASTER
|
||||||
|
|
||||||
|
Merged to master via `5f0eaa5`; final evidence commit `c0995b7` added
|
||||||
|
`artifacts/fleet-ops-final-localization/final-summary.md`. Master head at merge:
|
||||||
|
`c0995b762e1cbf37172a08e03645baa6b66aa8d5`. Details below are the in-progress working log
|
||||||
|
kept for reference.
|
||||||
|
|
||||||
|
Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head
|
||||||
|
(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the
|
||||||
|
already-checked-out branch name is used instead since it was verified freshly and
|
||||||
|
cleanly branched from current `origin/master` with a clean working tree; see
|
||||||
|
`docs/fleet-ops-final-localization/audit.md` for the naming note. Scope: a small,
|
||||||
|
targeted correction round only — explicitly not touching status-flow business logic,
|
||||||
|
the status evaluator, Data Quality resolution rules, return rules, RAGcore/MCP Hub, or
|
||||||
|
product scope.
|
||||||
|
|
||||||
|
- **Audit-driven gap sweep**: `docs/fleet-ops-final-localization/audit.md` documents
|
||||||
|
every remaining untranslated/incorrect string, raw-backend-error call site,
|
||||||
|
over-permissive allowlist entry, the static-greeting bug, and doc staleness found by
|
||||||
|
a dedicated Explore pass before any file was touched.
|
||||||
|
- **Remaining NL/FR translation gaps fixed**: role names actually translated (not just
|
||||||
|
labelled as translated) — `auth.json`/`demo.json` role keys, `audit.title` →
|
||||||
|
"Auditgeschiedenis"/"Piste d'audit", `columns.actor` → "Uitvoerder", `list.statusOpen`
|
||||||
|
→ "Openstaand", `ledger.filterRecent` → "Recentste", `scenarios.startScenario` →
|
||||||
|
"Scenario starten". Also found and fixed (via the new embedded-substring test below)
|
||||||
|
8 previously-missed mid-sentence "Audit trail" leaks across `demo.json`,
|
||||||
|
`quality.json`, `returns.json` that the old whole-string-identity test structurally
|
||||||
|
could not catch.
|
||||||
|
- **Central API-error localization**: new `frontend/src/api/errorMessages.ts`
|
||||||
|
(`describeApiError`) replaces the `err instanceof ApiError ? err.message : ...`
|
||||||
|
anti-pattern (which showed raw English for the common case) at all 13 call sites
|
||||||
|
across 7 files. Raw backend text is now only ever shown under a "Technical
|
||||||
|
details"/"Détails techniques" disclosure (new `ApiErrorNotice` component in
|
||||||
|
`PageChrome.tsx`); the primary message is always a localized title + explanation +
|
||||||
|
optional next step, keyed on the 32 known `AppError` codes, then known HTTP statuses
|
||||||
|
(401/403/404/409/422/500), then a fully generic fallback. `ApiError` was split out of
|
||||||
|
`client.ts` into a standalone `api/apiError.ts` (no `import.meta.env` dependency) so
|
||||||
|
`errorMessages.ts` is independently testable outside a Vite/browser context.
|
||||||
|
- **i18n allowlist tightened**: 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`) now that they're genuinely translated. Added 2 new tests:
|
||||||
|
one closing the embedded-English/Dutch-substring blind spot (mid-sentence phrase
|
||||||
|
leaks the whole-string check misses), one asserting no locale file contains
|
||||||
|
"MobilityOps" or the word "PoC".
|
||||||
|
- **`describeApiError` test coverage**: new `frontend/e2e/error-messages.spec.ts` (10
|
||||||
|
tests) — every known code/HTTP status has non-empty copy in all 3 locales, a known
|
||||||
|
code never surfaces raw backend text as the primary message (only via `.technical`),
|
||||||
|
unknown-code and unknown-status fallback chains behave correctly, and a drift guard
|
||||||
|
that greps the actual backend `AppError("CODE", ...)` call sites and fails if
|
||||||
|
`KNOWN_CODES` and the backend's real codes ever diverge (currently exactly in sync,
|
||||||
|
32 codes).
|
||||||
|
- **Time-dependent Europe/Brussels dashboard greeting**: new
|
||||||
|
`frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, DST-safe via
|
||||||
|
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })`, clock
|
||||||
|
injectable) + `useGreetingPeriod.ts` hook (30s poll for period rollover while the app
|
||||||
|
stays open, no reload). Replaces the previously-always-"Goedemorgen" static
|
||||||
|
`dashboard.json` title with 4 periods × 3 languages for both the greeting word and a
|
||||||
|
varying accompanying sentence (never "Goedenacht"). Tests: `greeting.spec.ts` (pure
|
||||||
|
boundary/DST unit tests) + `greeting-live.spec.ts` (6 real-browser tests via
|
||||||
|
Playwright's `page.clock` — all 8 required boundary times in all 3 languages, live
|
||||||
|
rollover without reload, language-switch behaviour, the "never Goedenacht" guard).
|
||||||
|
- **Found and fixed one real CSS regression along the way**: correctly translating
|
||||||
|
`roleOperationsManager` to the single unbreakable Dutch compound word
|
||||||
|
"Operationsmanager" (vs. the old two-word "Operations Manager", which could wrap)
|
||||||
|
pushed the topbar's `.operator` block past 1024px width, caught by the existing
|
||||||
|
`responsive-i18n.spec.ts` overflow test. Fixed with `overflow-wrap: anywhere` on
|
||||||
|
`.operator strong`/`small` and `min-width: 0` on their flex-item wrapper, not by
|
||||||
|
reverting the correct translation.
|
||||||
|
- Gates green so far: backend `pytest` 151 passed, `ruff check .` clean, `mypy app`
|
||||||
|
clean (49 files, unchanged — no backend Python touched this round); frontend `tsc`
|
||||||
|
clean, production build clean, full local Playwright suite **138 passed** (rebuilt
|
||||||
|
and restarted the local `web` container from source before this run).
|
||||||
|
- **Not yet done**: clean-checkout drill, commit/push, Unraid deployment of this fix
|
||||||
|
branch with live 3-language validation, the master merge (with the mandatory
|
||||||
|
`git fetch origin` / unexpected-change check first), and
|
||||||
|
`artifacts/fleet-ops-final-localization/final-summary.md`. Do not claim PASS on this
|
||||||
|
correction round until all of those are done and `git rev-parse HEAD` exactly matches
|
||||||
|
`/mnt/user/appdata/mobilityops/.deploy/source-revision`.
|
||||||
|
- Commits so far on this branch: `6deb955` (status flow + brand constant + message
|
||||||
|
codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new
|
||||||
|
flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test),
|
||||||
|
`a7ac5ed` (docs), `7851e80` (11D/11F i18n tests), `cda2c32` (clean-checkout
|
||||||
|
evidence), `2e4fb43` (evidence-summary localization fix, found live on Unraid).
|
||||||
|
Deployed commit: `2e4fb43f093bfbdb04c4f74eed1e6c6d9a03c069`.
|
||||||
|
|
||||||
|
## Live n8n + RAGcore integration (2026-08-04) — IN PROGRESS on feat/live-n8n-ragcore-integration
|
||||||
|
|
||||||
|
Branch `feat/live-n8n-ragcore-integration`, from master `c0995b7`. Full brief: treat n8n
|
||||||
|
(`https://n8n.itworx.tech`, existing shared instance) as a third integration layer
|
||||||
|
alongside RAGcore and MCP Hub, owning process orchestration only — Fleet Ops keeps all
|
||||||
|
business rules, authorization, transactions, audit and idempotency. Four canonical
|
||||||
|
workflows required: (1) Vehicle Return Orchestration, (2) Scheduled Data Quality Scan —
|
||||||
|
both pre-existing and now hardened; (3) RAGcore Procedure Sync, (4) Workflow Error
|
||||||
|
Handler — both net-new, not yet built.
|
||||||
|
|
||||||
|
- **Current-state audit**: `docs/live-ai-integration/n8n-current-state.md` documents the
|
||||||
|
live instance (reachable, production webhook base
|
||||||
|
`http://192.168.10.150:5678/webhook/mobilityops-return`), both existing workflows'
|
||||||
|
full node structure, and the findings that drove the security fixes below (webhook
|
||||||
|
Authentication was `None`; both HTTP nodes had `X-Service-Token` hardcoded as a literal
|
||||||
|
header value instead of a credential).
|
||||||
|
- **Security fixes applied and live-validated** (commits `b79d485`, `59cb4c0`): webhook
|
||||||
|
trigger now requires Header Auth (credential `Fleet Ops Webhook Trigger Token`, a new
|
||||||
|
token generated this round — value stored in `.env`/Unraid `.env` only, never
|
||||||
|
printed); the outbound callback HTTP node now uses a `Fleet Ops Service Token` Header
|
||||||
|
Auth credential instead of a literal header value (existing secret copied
|
||||||
|
clipboard-to-clipboard, never typed/echoed). Backend: `X-Fleet-Ops-Trigger-Token`
|
||||||
|
header added to the outbox dispatcher's POST (`backend/app/services/dispatcher.py`),
|
||||||
|
plus a new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting/env var. Also hardened
|
||||||
|
`_deliver_one` to treat a 2xx response with a non-JSON-object body as a retryable
|
||||||
|
failure (`malformedResponse`) instead of an unhandled exception — a real failure mode
|
||||||
|
hit live when a workflow errors before its "Respond to Webhook" node runs; regression
|
||||||
|
test `test_deliver_one_treats_empty_2xx_body_as_failure` added. Live-validated: curl
|
||||||
|
probe without the header → `403`; with the header → pass-through; one real end-to-end
|
||||||
|
vehicle return produced one correct execution visible in both n8n and Fleet Ops
|
||||||
|
Audit/Automation. Both workflows explicitly `Publish`ed after the fixes (the editor
|
||||||
|
does not go live on save alone) and both canonical-renamed ("Fleet Ops — Vehicle
|
||||||
|
Return Orchestration", "Fleet Ops — Scheduled Data Quality Scan").
|
||||||
|
- **RAGcore real contract discovered** (not the speculative one the adapter was built
|
||||||
|
against): OpenAPI at `/openapi.json`, health at `/health/live`/`/health/ready` (not
|
||||||
|
`/health`), ingestion via `POST /v1/uploads`, answers via `POST /v1/answers` with
|
||||||
|
`requested_space_ids`, control-plane endpoints require an `Idempotency-Key` header.
|
||||||
|
Bootstrapped a `fleet-ops` application + knowledge space + grant on the real server at
|
||||||
|
`http://192.168.10.150:1237`. **Blocked**: credential issuance for that application
|
||||||
|
failed identically via both the raw API and the admin UI ("authoritative
|
||||||
|
service-account state rejected issuance") — an apparent privilege boundary beyond the
|
||||||
|
interactive admin session. User chose to issue the credential themselves via another
|
||||||
|
mechanism and hand over the token; not yet received. `RAGcoreKnowledgeProvider`
|
||||||
|
(`backend/app/services/knowledge/ragcore.py`) still targets the old speculative
|
||||||
|
endpoints and needs fixing once that token arrives — approved, not started.
|
||||||
|
- **Repository source of truth started** (task in progress): `n8n/workflows/` now holds
|
||||||
|
cleaned definitions for workflows 1-2 — `fleet-ops-vehicle-return.json` (sha256
|
||||||
|
`e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e`),
|
||||||
|
`fleet-ops-data-quality-scan.json` (sha256
|
||||||
|
`cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e`) — both credential
|
||||||
|
auth referenced by name only, no secret values. Reconstructed from direct verified
|
||||||
|
inspection of every live node, **not** a literal n8n export/download: the UI's "..."
|
||||||
|
menu has no Download option in this n8n version, and clipboard-based
|
||||||
|
copy/`navigator.clipboard.readText()` extraction timed out twice. Flagged as a known
|
||||||
|
limitation for the final evidence doc. `n8n/workflows/MANIFEST.md` records canonical
|
||||||
|
name/purpose/trigger/contract/credentials/live ID/active-status/checksum for all 4
|
||||||
|
workflows (3-4 marked not-yet-built). `n8n/workflows/check_drift.py` compares a repo
|
||||||
|
definition against the live workflow via n8n's Public API (`X-N8N-API-KEY`, read-only,
|
||||||
|
never auto-overwrites). The old root-level `n8n/mobilityops-return-processing.json`
|
||||||
|
and `n8n/mobilityops-scheduled-quality-scan.json` (pre-integration starters, still
|
||||||
|
carrying the literal-token pattern) are removed; `deploy/unraid/setup-existing-n8n.sh`,
|
||||||
|
`setup-scheduled-scan.sh`, `Makefile` (`n8n-setup`, `n8n-setup-scan`) and
|
||||||
|
`docs/17-runbook.md` updated to import from `n8n/workflows/` and to document the
|
||||||
|
now-required manual credential-creation step (credentials are never scripted or
|
||||||
|
committed).
|
||||||
|
- **Explicitly deferred/forbidden this phase** (per brief): daily AI ops brief, email,
|
||||||
|
Slack, automatic vehicle-status changes, customer communication, billing, general
|
||||||
|
monitoring, autonomous MCP actions. An automatic demo-reset workflow may only be
|
||||||
|
prepared, not activated, once Fleet Ops goes public.
|
||||||
|
- **Workflow 4 (Workflow Error Handler) built and live-validated** (commit pending):
|
||||||
|
new backend endpoint `POST /api/v1/integrations/n8n/workflow-error`
|
||||||
|
(`backend/app/api/routers/integrations.py`, service-token auth, Pydantic
|
||||||
|
`WorkflowErrorReportIn`/`WorkflowErrorReportResult` in `backend/app/schemas.py`),
|
||||||
|
idempotent on `execution_id` via the same audit-precheck pattern as
|
||||||
|
`/return-callback`; new test coverage in `backend/tests/test_integrations.py` (all
|
||||||
|
green, 152 tests total, ruff/mypy clean). **This endpoint had to be deployed to the
|
||||||
|
live Unraid server** (`git archive` → `scp` → extract preserving `.env` → `docker
|
||||||
|
compose up --build -d api`, no migration needed) before the live n8n test could reach
|
||||||
|
it — the auto-mode classifier correctly blocked the first `scp` attempt as a
|
||||||
|
production-infra action; user approved, then it was deployed and verified
|
||||||
|
(`/health` OK, new endpoint returns 422 on empty body instead of 404).
|
||||||
|
Built "Fleet Ops — Workflow Error Handler" (live ID `Xppn2rAEqUuyiCJF`) in n8n:
|
||||||
|
Error Trigger → Code node (derives safe error_category/summary/etc. from n8n's error
|
||||||
|
payload) → HTTP node (POST to the new endpoint, Header Auth via the existing "Fleet
|
||||||
|
Ops Service Token" credential). Hit and fixed two real bugs during live testing: (1)
|
||||||
|
Code node's default "Run Once for All Items" mode doesn't bind `$json` to the current
|
||||||
|
item — switched to "Run Once for Each Item" and `return {json:...}` instead of
|
||||||
|
`return [{json:...}]`; (2) every HTTP-body field expression ended up with a stray
|
||||||
|
trailing space (from the code-editor's bracket-autoclose leaving one extra character
|
||||||
|
after the `End`+`Backspace×2` fix), which broke the `failed_at` datetime parse and the
|
||||||
|
`error_category` literal match — found via the raw request dump in n8n's error
|
||||||
|
panel, fixed with one more `Backspace` per field. Live-validated: mock Error Trigger
|
||||||
|
data → real `200 {"status":"registered"}` from Fleet Ops; re-run → `"already_registered"`
|
||||||
|
(idempotency confirmed); wired as the Error Workflow on workflows 1 and 2 (via each
|
||||||
|
workflow's Settings modal); confirmed the Error Handler itself has `Error Workflow: -
|
||||||
|
No Workflow -` (no recursive loop). With user approval, also ran a genuine induced
|
||||||
|
failure on workflow 2 (temporarily pointed its HTTP node at a nonexistent path,
|
||||||
|
published, ran it, confirmed it failed as expected, immediately reverted and
|
||||||
|
republished, confirmed healthy again) — this proved the target workflow's own error
|
||||||
|
path works, but n8n did not auto-invoke the Error Handler for that *manual* editor
|
||||||
|
test run (n8n's Error Workflow trigger only fires for unattended/production
|
||||||
|
executions), so a fully automatic schedule/webhook-triggered cascade into the handler
|
||||||
|
was not observed live this round — noted as a known limitation.
|
||||||
|
Exported the verified definition to `n8n/workflows/fleet-ops-error-handler.json` (same
|
||||||
|
manual-reconstruction caveat as workflows 1-2: no literal export/download available),
|
||||||
|
updated `n8n/workflows/MANIFEST.md` (all 4 workflows, workflow 3 still not-built) and
|
||||||
|
`check_drift.py`'s known-workflows list.
|
||||||
|
- **Integration status page enriched with real per-workflow evidence** (commit
|
||||||
|
`4049c0c`): `N8nIntegrationStatus` now returns `workflows: N8nWorkflowEvidence[]`
|
||||||
|
(the 4 canonical workflows, each with real evidence — latest successful outbox
|
||||||
|
delivery for the return workflow, latest *service*-triggered `data_quality_scan_run`
|
||||||
|
audit event for the scan workflow so a manual UI-triggered scan doesn't fake n8n
|
||||||
|
evidence, latest `n8n_workflow_failure_registered` for the error handler, always
|
||||||
|
`built: false` / no evidence for the not-yet-built RAGcore sync), plus
|
||||||
|
`expected_workflow_count`/`known_workflow_count` and an `error_handler` summary
|
||||||
|
(total registered, latest failure + which workflow). New tests in
|
||||||
|
`backend/tests/test_integration_status.py` (all green, 159 backend tests total,
|
||||||
|
ruff/mypy clean). Frontend: `Automation.tsx` renders this as a localized workflow
|
||||||
|
table (EN/NL/FR, new `integrations:workflows.*` keys, technical workflow names under
|
||||||
|
a "Technical details" disclosure per the existing progressive-disclosure pattern).
|
||||||
|
Verified live in the browser both locally (Dutch locale, disclosure expand/collapse
|
||||||
|
confirmed) and **on the deployed Unraid server after this round's deploy**: correctly
|
||||||
|
shows "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 session's live
|
||||||
|
testing. Deployed to Unraid (commit `4049c0c6b12fef3d948cd31f21119044143320d8`,
|
||||||
|
rebuilt both `api` and `web`, `/health` OK) — user re-approved this second deploy
|
||||||
|
separately from the first.
|
||||||
|
- **Operational lesson learned this round**: `docker compose run --rm api pytest` does
|
||||||
|
**not** reliably pick up source edits without an explicit `docker compose build api`
|
||||||
|
first — a test file edit silently kept running against the stale built image (test
|
||||||
|
count didn't change) until rebuilt. Always `docker compose build api` (and `web` for
|
||||||
|
frontend changes) before trusting a green result after backend/frontend edits in this
|
||||||
|
repo.
|
||||||
|
- **WF1 acceptance gap fixed and live**: `check_drift.py`-style re-inspection of workflow
|
||||||
|
1 during this round's acceptance pass found the `Record follow-up` HTTP node had no
|
||||||
|
explicit timeout and "Retry On Fail" disabled — a real gap against the brief's
|
||||||
|
timeouts/bounded-retries requirement (WF2 already had this). Fixed live: Retry On Fail
|
||||||
|
(3 tries, 1000ms wait) + a 15000ms Timeout option, published (version note "Add bounded
|
||||||
|
retries (3x) and a 15s timeout to the Fleet Ops callback call"). Repo definition and
|
||||||
|
manifest checksum synced (`n8n/workflows/fleet-ops-vehicle-return.json`,
|
||||||
|
`MANIFEST.md`, new checksum `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609`,
|
||||||
|
commit `0562893`).
|
||||||
|
- **RAGcore credential issuance re-attempted and still blocked (user explicitly
|
||||||
|
authorized Claude to self-issue this round)**: tried the RAGcore admin UI's "Issue
|
||||||
|
credential" form for the `fleet-ops` application (logged in as Platform Admin, the
|
||||||
|
highest visible role) with name `n8n-ragcore-procedure-sync` and scope `sources:sync`
|
||||||
|
only. Submission failed with the same generic "Something went wrong. The credential
|
||||||
|
could not be issued with those values." page, this time carrying a trace reference
|
||||||
|
`1955c6a8968c4941a22a1faef39e17a7`. Inspected the RAGcore OpenAPI spec for this admin
|
||||||
|
endpoint (`POST /admin/control/applications/{application_id}/credentials`) — no
|
||||||
|
documented validation constraint explains the rejection (no 422, no field errors); the
|
||||||
|
`fleet-ops` application itself lists as ordinary/`Active` with no visible lock flag in
|
||||||
|
the applications table. This is the same failure signature as the earlier raw-API
|
||||||
|
attempt (400 "authoritative service-account state rejected issuance"): two independent
|
||||||
|
paths (raw API, and now the admin UI as the top admin role) both hit an opaque
|
||||||
|
server-side rejection with a trace ID. This is conclusive evidence the block is a
|
||||||
|
deliberate RAGcore-side policy or a RAGcore-side bug, not a Fleet Ops permission or
|
||||||
|
request-shape problem — nothing further is fixable from the Fleet Ops side or through
|
||||||
|
browser automation. Whoever operates the RAGcore instance needs to look up trace
|
||||||
|
`1955c6a8968c4941a22a1faef39e17a7` (and the earlier API rejection) in RAGcore's own
|
||||||
|
logs to find the real cause.
|
||||||
|
- **WF2 acceptance gap fixed and live**: continuing the acceptance pass to WF2 found it
|
||||||
|
had the *same* Retry On Fail gap as WF1 (its 15s timeout was already set, but retries
|
||||||
|
were off — the earlier note that "WF2 already had this" was wrong on the retry half).
|
||||||
|
Fixed live the same way (3 tries, 1000ms wait), published (version note "Add bounded
|
||||||
|
retries (3x) to the quality-scan HTTP call"). Repo definition and manifest checksum
|
||||||
|
synced (`n8n/workflows/fleet-ops-data-quality-scan.json`, `MANIFEST.md`, new checksum
|
||||||
|
`c0d46e0519118e6336e35c4ea2a67edb2f14bd007909ccf9256c93733751244a`, commit `167bf49`).
|
||||||
|
- **WF4 has the same gap on its own outbound call, but is currently un-fixable**: WF4's
|
||||||
|
"Report failure to Fleet Ops" HTTP node also has no timeout and no Retry On Fail. Began
|
||||||
|
the same fix (added a 15000ms Timeout option, toggled Retry On Fail on) but n8n's
|
||||||
|
autosave started failing with "Unauthorized" mid-edit, and a fresh tab confirmed the
|
||||||
|
n8n browser session had expired (redirected to `/signin`) — so nothing was saved and
|
||||||
|
the live WF4 definition is unchanged from before this round (no partial/broken state).
|
||||||
|
This is a minor, best-effort-only gap (WF4 is the error notifier itself, not a primary
|
||||||
|
business flow, and it already reports failures with `On Error: Stop Workflow` so a
|
||||||
|
failed error-report is visible in n8n's own execution history even without retries) —
|
||||||
|
not blocking, but worth finishing once someone re-authenticates the n8n browser
|
||||||
|
session.
|
||||||
|
- **RAGcore credential-issuance blocker root-caused and fixed (in RAGcore itself, with
|
||||||
|
explicit owner approval)**: with read access to the sibling `C:\Projects\RAGcore`
|
||||||
|
checkout, traced "authoritative service-account state rejected issuance" to a genuine
|
||||||
|
cross-transaction race in RAGcore's own dependency injection
|
||||||
|
(`src/ragcore/api/v1/control/dependencies.py`). `get_control_application` and
|
||||||
|
`get_credential_service` each independently opened their own `factory.begin()`
|
||||||
|
database transaction. Issuing a credential for a brand-new service account does, in one
|
||||||
|
request: (1) INSERT the service account via the first dependency's transaction, then
|
||||||
|
(2) immediately re-read it via the second dependency's *separate, uncommitted* transaction
|
||||||
|
— invisible under READ COMMITTED isolation until the first transaction commits, which
|
||||||
|
only happens after the endpoint returns. This made every fresh-service-account credential
|
||||||
|
issuance fail, 100% of the time, via both the raw API and the admin UI (explaining the
|
||||||
|
identical failure signature on both paths). RAGcore's own tests never caught this because
|
||||||
|
they override these dependencies with an in-memory fake that ignores transaction boundaries
|
||||||
|
entirely. Fixed by introducing one shared, cached `get_control_session` dependency that
|
||||||
|
both providers now depend on via `Depends(...)`, so they share one transaction per request.
|
||||||
|
Verified: RAGcore's own test suite (64 tests across `tests/web`, `tests/contract/api/control`,
|
||||||
|
`tests/security/identity`, `tests/unit/domain/control`, `tests/api`) passes, ruff and mypy
|
||||||
|
clean. Deployed to the live RAGcore instance (also on the Unraid host, `ragcore-app-1` on
|
||||||
|
port 1237 — a shared service also used by other ITWorx projects) via `docker compose build`
|
||||||
|
+ `up -d`, with explicit owner approval before both the code change and the deploy.
|
||||||
|
Confirmed fixed live: issuing a credential for `fleet-ops` (name
|
||||||
|
`n8n-ragcore-procedure-sync`, scope `sources:sync`) now succeeds (prefix `rc_sa_6fc51e`).
|
||||||
|
The plaintext token was never printed/logged — copied via RAGcore's own "Copy" button and
|
||||||
|
pasted directly into a new n8n Header Auth credential named **"RAGcore Sync Token"**
|
||||||
|
(header `Authorization: Bearer <token>`), ready for workflow 3.
|
||||||
|
- **Exact next action (superseded by the entry below)**: task #86 (build workflow 3,
|
||||||
|
RAGcore Procedure Sync) and the `RAGcoreKnowledgeProvider` adapter rewrite (to the real
|
||||||
|
inspected contract — `/health/live`, `/health/ready`, `POST /v1/uploads`, `POST
|
||||||
|
/v1/search`/`/v1/context`/`/v1/answers`) are now unblocked — the "RAGcore Sync Token" n8n
|
||||||
|
credential exists and works. WF4's own timeout/retry gap is still open pending n8n browser
|
||||||
|
re-authentication (minor, non-blocking, see above). Both #90 and #91 should be revisited
|
||||||
|
once workflow 3 is actually built, since they currently document it as blocked.
|
||||||
|
|
||||||
|
- **Second RAGcore bug found, fixed and deployed (with explicit owner approval, same pattern
|
||||||
|
as the transaction-race fix above)**: with the "RAGcore Sync Token" credential in hand,
|
||||||
|
the n8n "Upload to RAGcore" node still returned a persistent 401 on every item. Traced via
|
||||||
|
direct RAGcore source inspection (`C:\Projects\RAGcore`) to a genuine second, independent
|
||||||
|
gap: no code path in RAGcore converted an incoming `Authorization: Bearer <token>` header
|
||||||
|
into a `request.state.principal` for any `/v1/*` route — only browser session cookies were
|
||||||
|
ever accepted, even though the credential-verification logic
|
||||||
|
(`ServiceAccountCredentialService.verify()`) existed and was unit-tested. This blocks any
|
||||||
|
machine caller (n8n, and eventually Fleet Ops's own `RAGcoreKnowledgeProvider` adapter)
|
||||||
|
from ever authenticating to `/v1/uploads`. Fixed additively, scoped to `/v1/uploads` only
|
||||||
|
per owner instruction (search/context/answers left for later): new
|
||||||
|
`CredentialRepository.get_by_id()` (Postgres + in-memory), new
|
||||||
|
`ServiceAccountCredentialService.authenticate()` (parallel to the existing `verify()`, not
|
||||||
|
a refactor of it), and a new `get_upload_principal` FastAPI dependency
|
||||||
|
(`src/ragcore/api/v1/uploads/dependencies.py`) that falls back to the Bearer header when
|
||||||
|
there is no session principal, wired into `uploads/routes.py` in place of the session-only
|
||||||
|
`get_principal`. New/updated tests in `tests/security/identity/test_credentials.py` and
|
||||||
|
`tests/security/uploads/test_upload_security.py` (bearer-token accept/reject paths, the
|
||||||
|
existing route test's stale `get_principal` override fixed to `get_upload_principal`).
|
||||||
|
Verified: RAGcore's own test suite — 377 passed in the affected `tests/security`,
|
||||||
|
`tests/unit`, `tests/api` trees (3 unrelated pre-existing failures: two need Windows
|
||||||
|
symlink privileges the sandbox doesn't have, one is a git-connector fixture mismatch; a
|
||||||
|
separate architecture-boundary failure in `application/ingestion/handler.py` belongs to
|
||||||
|
unrelated in-progress work by a different concurrent agent on the same RAGcore checkout,
|
||||||
|
confirmed via `git status`/`git log` — not touched by this fix). Ruff and mypy clean on
|
||||||
|
every changed file. Deployed to the live RAGcore instance (`ragcore-app-1` on Unraid, port
|
||||||
|
1237 internally, fronted by `rag.itworx.tech` — note the *admin UI* and the *API* share
|
||||||
|
one process/origin, `/v1/uploads` is reachable at `https://rag.itworx.tech/v1/uploads`,
|
||||||
|
**not** `ragcore.itworx.tech`, which only appears in RFC7807 problem-type URLs) by copying
|
||||||
|
the 6 changed source files directly into the server checkout and `docker compose build
|
||||||
|
app && up -d --no-deps app` (deliberately not committing to RAGcore's git history or
|
||||||
|
touching the `worker` service, since a different agent has substantial unrelated
|
||||||
|
uncommitted work in that same working tree). Confirmed live with a garbage token (still
|
||||||
|
correctly 401) and then with a freshly-issued, correctly-scoped real token (403
|
||||||
|
`UPLOAD_TARGET_FORBIDDEN` against a dummy space ID — i.e. authentication succeeded,
|
||||||
|
authorization correctly rejected the wrong space — proving the fix end-to-end before
|
||||||
|
touching n8n at all).
|
||||||
|
- **Root cause of the n8n-side 401 found and fixed**: separately from the RAGcore bug above,
|
||||||
|
the "Upload to RAGcore" HTTP node's Authentication was set to Header Auth, but **no
|
||||||
|
credential had ever actually been attached** to that picker — so the node was sending no
|
||||||
|
`Authorization` header at all, which produces the identical 401 to a malformed one (easy
|
||||||
|
to conflate with the RAGcore-side bug, which is why fixing RAGcore alone didn't resolve
|
||||||
|
the symptom). There was already an unused "RAGcore Sync Token" n8n credential sitting
|
||||||
|
around from the earlier session (its value likely never actually got saved when it was
|
||||||
|
first created, or was created but never selected on this node — not conclusively
|
||||||
|
determined). Owner attached it and set Name=`Authorization`,
|
||||||
|
Value=`Bearer <freshly-issued token, scope sources:sync>` (a new credential issued via the
|
||||||
|
RAGcore admin UI at `/admin/control/applications/c20ac48a-d57b-4c68-9bd1-564f49c1a473/credentials/new`
|
||||||
|
specifically for this, service account "n8n Procedure Sync (production)"; the earlier
|
||||||
|
diagnostic-only credential used to prove the RAGcore fix was revoked afterward via direct
|
||||||
|
SQL `UPDATE identity.service_account_credentials SET revoked_at = now() ...` since the
|
||||||
|
admin UI has no revoke button).
|
||||||
|
- **Workflow 3's "Upload to RAGcore" node live-validated end-to-end, real data**: ran the
|
||||||
|
full workflow via n8n's "Execute workflow" (Schedule Trigger → List procedures → Prepare
|
||||||
|
uploads → Upload to RAGcore). All 33 items succeeded — each output item is a real
|
||||||
|
`AcceptedJob` (`job_id`/`status_url`), not error output. Independently confirmed at the
|
||||||
|
database level (not just trusting the n8n UI): `select count(*) from jobs.jobs where
|
||||||
|
operation='ingest_upload' and created_at > now() - interval '5 minutes'` → **33**, on the
|
||||||
|
live RAGcore Postgres.
|
||||||
|
- **n8n browser-automation notes for this environment** (worth knowing before attempting
|
||||||
|
canvas interaction again): (1) an n8n NPS survey modal (`role=dialog`, "We've been busy")
|
||||||
|
intermittently covers the whole canvas and silently eats every click underneath it until
|
||||||
|
removed; (2) canvas node positions reported by `getBoundingClientRect()` drift between
|
||||||
|
successive tool calls in a way that made coordinate-based `computer` clicks and even
|
||||||
|
`find`-ref-based clicks land on the wrong element repeatedly this session (dozens of failed
|
||||||
|
attempts, multiple different coordinate-math theories, none reliable) — directly setting
|
||||||
|
`.vue-flow__transformationpane`'s inline `style.transform` to force a node into view
|
||||||
|
**desyncs vue-flow's own internal pan/zoom state**, making the problem worse, not better;
|
||||||
|
(3) what actually worked reliably every time: calling native `.click()` directly via JS on
|
||||||
|
a plain `<button>` element (e.g. the toolbar's "Execute workflow" button) — canvas *node*
|
||||||
|
interaction (double-click to open a node's parameter panel) was never reliably achieved
|
||||||
|
via any automated method this session; the owner opened/edited the node manually instead.
|
||||||
|
A plain synthetic `dispatchEvent(new MouseEvent(...))` sequence (pointerdown/mousedown/
|
||||||
|
pointerup/mouseup/click/dblclick, even with correct `clientX`/`clientY`/`detail`) does
|
||||||
|
**not** trigger vue-flow's node click handling at all — it appears to require a genuinely
|
||||||
|
trusted (real CDP-driven) pointer event, consistent with vue-flow's drag/zoom gesture
|
||||||
|
system depending on native pointer capture.
|
||||||
|
- **Exact next action (superseded further below)**: build and wire the workflow's final
|
||||||
|
"Summarize sync result" Code node (count successes/failures across the 33 items) and a
|
||||||
|
closing HTTP node reporting to Fleet Ops's already-deployed `POST
|
||||||
|
/api/v1/integrations/n8n/procedures-sync-result` (task #86, still in progress — the sync
|
||||||
|
itself now works, this is the last piece). Then publish the workflow (currently still a
|
||||||
|
draft/unpublished), update `n8n/workflows/MANIFEST.md` and add
|
||||||
|
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` as the repo source-of-truth
|
||||||
|
definition, and revisit `artifacts/live-ai-integration/final-summary.md` (documents WF3
|
||||||
|
as blocked — no longer true).
|
||||||
|
|
||||||
|
- **Verified the 33-item sync is genuinely fully ingested, not just accepted**: checked at
|
||||||
|
every RAGcore pipeline stage on the live instance, not just trusting the n8n "success"
|
||||||
|
status (which can mask individual failures under `On Error: Continue`). All 33
|
||||||
|
`ingest_upload` jobs have `status='succeeded'` in `jobs.jobs`; 33 rows exist in
|
||||||
|
`content.documents`; 372 chunks were generated in `content.chunks`; 241 vectors are
|
||||||
|
indexed in the `rag_dense_nomic-embed-text_v1` Qdrant collection filtered specifically to
|
||||||
|
Fleet Ops's `space_id` (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`) — matching the expected
|
||||||
|
leaf-chunk count (structural/parent chunks in the hierarchy aren't separately embedded).
|
||||||
|
11 unique procedures × 3 languages = 33, all present.
|
||||||
|
|
||||||
|
- **`RAGcoreKnowledgeProvider` rewritten against the real contract (task #93), tested, but
|
||||||
|
NOT switched on in production — genuinely blocked on a RAGcore-side gap, not a Fleet Ops
|
||||||
|
problem**: rewrote `backend/app/services/knowledge/ragcore.py` end to end against
|
||||||
|
RAGcore's actual `/v1/answers` and `/health/ready` contracts (previously a best-effort
|
||||||
|
guess against an unreachable instance). Health now calls the real `GET /health/ready`.
|
||||||
|
`ask()` calls `POST /v1/answers` with `Authorization: Bearer <token>` and
|
||||||
|
`requested_space_ids: [settings.ragcore_space_id]`, maps RAGcore's `answerability` enum
|
||||||
|
conservatively (`answerable`/`partially_answerable` *with* non-empty citations →
|
||||||
|
`grounded`, everything else → `insufficient`, any transport/parse/non-200 failure →
|
||||||
|
`unavailable`, matching the architecture's never-fabricate rule). Added
|
||||||
|
`RAGCORE_SPACE_ID` setting (`.env.example`, `compose.yaml`). New tests in
|
||||||
|
`backend/tests/test_knowledge.py` (connection-error degrade, missing-space-id short
|
||||||
|
circuits without a network call, health ready/degraded/unreachable, grounded citation
|
||||||
|
mapping, not-answerable and answerable-without-citations both correctly map to
|
||||||
|
`insufficient` with an empty answer, non-200 and malformed-body both degrade to
|
||||||
|
`unavailable`) — **23 passed** in that file, **172 passed** full suite, ruff clean, mypy
|
||||||
|
0 issues/50 files.
|
||||||
|
**Discovered while live-testing against RAGcore with a freshly-issued, correctly-scoped
|
||||||
|
credential** (`Fleet Ops Knowledge Assistant (production)`, scope `answer`, space
|
||||||
|
`f4c91e49-...`): authentication now genuinely succeeds (past the 401 stage — confirmed
|
||||||
|
via `/health/ready` returning `200 {"status":"ok", ...}` with the same token), but **both
|
||||||
|
`POST /v1/answers` and `POST /v1/search` return `503`
|
||||||
|
`ANSWERS_UNAVAILABLE`/`SEARCH_UNAVAILABLE`** for every request. Root-caused by reading
|
||||||
|
`src/ragcore/main.py`'s app-startup/lifespan code directly: it constructs and assigns
|
||||||
|
`app.state.database_engine`, `session_factory`, `qdrant_client`,
|
||||||
|
`query_lab_service`, `profile_activation_service`, and the OIDC session services — but
|
||||||
|
**never constructs or assigns `app.state.search_application`, `context_application`, or
|
||||||
|
`answer_application`** anywhere in the codebase (confirmed via a repo-wide grep — the
|
||||||
|
three `get_*_application` dependency functions exist and correctly raise their `503
|
||||||
|
*_UNAVAILABLE` problem when the state attribute is absent, exactly as designed, but
|
||||||
|
nothing ever populates it). This is not a config toggle Fleet Ops is missing and not
|
||||||
|
something introduced by today's auth fixes — the retrieval/generation subsystem's route
|
||||||
|
handlers and dependencies are scaffolded end-to-end but were never wired into the running
|
||||||
|
application on this RAGcore deployment. Only masked until today because every call to
|
||||||
|
these routes previously 401'd on auth before ever reaching this check.
|
||||||
|
**Decision: `KNOWLEDGE_PROVIDER` stays `demo` in production.** Flipping it to `ragcore`
|
||||||
|
right now would replace the currently-working demo Knowledge Assistant with one that
|
||||||
|
correctly, honestly, but uselessly reports "unavailable" for every question — strictly
|
||||||
|
worse for the live demo. The adapter code itself is finished, correct, and safe to ship
|
||||||
|
(already committed-worthy), and switching providers is a one-line env var flip
|
||||||
|
(`KNOWLEDGE_PROVIDER=ragcore` + set `RAGCORE_API_TOKEN`/`RAGCORE_SPACE_ID`) the moment
|
||||||
|
RAGcore's own operator wires up `search_application`/`answer_application` on their side.
|
||||||
|
The freshly-issued credential (`Fleet Ops Knowledge Assistant (production)`, scope
|
||||||
|
`answer`) was left active/unused in RAGcore, ready for that day.
|
||||||
|
|
||||||
|
- **MCP Hub registration (task #94/#95) — full new connector built and validated locally
|
||||||
|
in the sibling `C:\Projects\ITWorx_MCP_Hub` checkout, not committed or deployed**: the
|
||||||
|
Hub's admin UI (`mcp.itworx.tech`, real production instance, 7 pre-existing projects —
|
||||||
|
DevRunbook, ForgeFlow×2, General Infrastructure, GeoIntel, Ludarium) has **no self-service
|
||||||
|
"add project/connector" flow** — its own Settings page states "No writable settings are
|
||||||
|
available in this browser until the control API publishes an authorized configuration
|
||||||
|
schema" (Hub is on release `1.0.0-rc`, UI is read-only/observability-only today).
|
||||||
|
Registering MobilityOps therefore required building a genuinely new connector in the
|
||||||
|
Hub's own repo, following its `gitea` connector as the closest real template (external
|
||||||
|
HTTPS API, shared-secret auth) rather than `knowledge` (which turned out to be an
|
||||||
|
in-memory filesystem index, not an HTTP client, despite the name suggesting otherwise).
|
||||||
|
Built, with explicit owner approval given the Hub's own `CLAUDE.md` restricts autonomous
|
||||||
|
action to local-checkout work only (registry pushes and production startup are separate,
|
||||||
|
explicitly-gated checkpoints, not done this round):
|
||||||
|
- `packages/connector_kit/mobilityops.py` — `MobilityOpsSettings`/`MobilityOpsClient`
|
||||||
|
(HTTPS-only, same-origin-redirect-enforced, `X-Service-Token`/`X-Client-Id` headers,
|
||||||
|
path-safety-validated `vehicle_ref`), mirroring `gitea.py`'s hardening exactly.
|
||||||
|
- `connectors/mobilityops/{__init__,server,fake}.py` — `MobilityOpsConnector` exposing 4
|
||||||
|
read-only tools (`mobilityops.operations.summary`, `.attention.list`, `.vehicle.get`,
|
||||||
|
`.knowledge.search`), each `governed_tool`-wrapped, envelope-wrapped, field-mapped from
|
||||||
|
Fleet Ops's real `/api/v1/integrations/mcp/*` response shapes.
|
||||||
|
- `services/runtime/connector_mobilityops.py` + `dispatch.py` registration.
|
||||||
|
- Catalog: `catalog/connectors/mobilityops-default.yaml` (ConnectorTemplate),
|
||||||
|
`catalog/tools/mobilityops-*.yaml` (4 ToolManifests), `catalog/capability-packs/
|
||||||
|
mobilityops-reader.yaml` (a **new**, narrowly-scoped pack — deliberately not added to
|
||||||
|
the shared `project-reader` pack other projects use, to avoid granting them
|
||||||
|
MobilityOps access), `catalog/projects/mobilityops.yaml` (Project, `applicationUrl`
|
||||||
|
set to the real internal `http://192.168.10.150:1236`, not an invented public domain).
|
||||||
|
- Compose wiring across all three files (`docker-compose.yml`, `.prod.yml`,
|
||||||
|
`.blueprint.yml`) plus a new `mobilityops_service_token` Docker secret.
|
||||||
|
- Fixed ~13 regressions this surfaced in the Hub's own existing test suite — all
|
||||||
|
legitimate guard-rail tests (hardcoded service/tool/secret allowlists, a
|
||||||
|
`CONNECTOR_GATEWAYS`/`FakeContextForge._gateway_tools` registration gap that was a
|
||||||
|
**real production wiring miss**, not just a test-fixture gap: without it, the reconcile
|
||||||
|
step would have raised `"virtual server resolved to an empty tool allowlist"` against
|
||||||
|
the live Hub too) — not scope creep, this is exactly the Hub's own established pattern
|
||||||
|
for registering a new connector, verified by reading how `gitea`/`knowledge`/`unraid`
|
||||||
|
each touch the same ~15 files.
|
||||||
|
- Verified: 15 new connector tests pass; full Hub suite **383 passed, 0 failed, 5 skipped
|
||||||
|
(pre-existing)**; `ruff check`/`ruff format --check` clean; `mypy` clean across 108
|
||||||
|
source files; `scripts/check_boundaries.py` passes (import-direction rules respected —
|
||||||
|
the new connector only imports from `packages/connector_kit`, never `services/*`
|
||||||
|
directly); `scripts/validate_pack.py` schema-validates the catalog cleanly (68
|
||||||
|
documents, up from 61 — exactly the 7 new files) with the only remaining failure being
|
||||||
|
the Hub's own git-cleanliness gate for manifest regeneration, which requires a commit.
|
||||||
|
- **Not done, deliberately**: no commit (the Hub repo has substantial unrelated
|
||||||
|
in-progress work from a different concurrent agent — `WP-235` frontend redesign,
|
||||||
|
`BUILD_STATE.json`/`CURRENT_STATE.md`/`implementation/WORK_PLAN.json` all show as
|
||||||
|
modified by them, not by this session — committing broadly risks entangling that
|
||||||
|
work); no registry push; no production deployment/restart of the live
|
||||||
|
`itworx-mcp-hub-*` stack. Per the Hub's own `CLAUDE.md` approval boundaries, those are
|
||||||
|
separate, explicitly-gated checkpoints requiring their own fresh approval, and a real
|
||||||
|
`MOBILITYOPS_ENDPOINT`/`mobilityops_service_token`/`MOBILITYOPS_PROJECTS_JSON`
|
||||||
|
production configuration still needs to be decided before any of that could run.
|
||||||
|
- **MCP Hub connector — committed** as `f107544` ("Add MobilityOps (Fleet Ops) read-only
|
||||||
|
connector", 28 files) in `C:\Projects\ITWorx_MCP_Hub`, `main` branch. Not pushed to the
|
||||||
|
registry and not deployed to the live Hub stack — those remain separate, explicitly-gated
|
||||||
|
checkpoints per the Hub's own `CLAUDE.md` approval boundaries.
|
||||||
|
|
||||||
|
- **RAGcore search/context/answer application wiring (task #96) — implemented, tested,
|
||||||
|
committed as `a2905cc`** in `C:\Projects\RAGcore`, `main` branch (not pushed, not
|
||||||
|
deployed to the live RAGcore instance). This closes the gap documented above:
|
||||||
|
`search_application`/`context_application`/`answer_application` are now actually
|
||||||
|
constructed in `create_app()` instead of being permanently absent.
|
||||||
|
- `src/ragcore/application/retrieval/production_executor.py` (new) — `RetrievalPipeline`
|
||||||
|
(embed via Ollama -> dense+sparse Qdrant search -> RRF fuse -> rerank via Ollama),
|
||||||
|
reusing the same flow already proven by the Query Lab's `PipelineQueryLabExecutor`
|
||||||
|
(left untouched). Feeds `ProductionSearchExecutor` and `ProductionContextExecutor` from
|
||||||
|
one shared per-request run, so `SearchHit`'s `FusedCandidate` (required by
|
||||||
|
`SearchService`'s own provenance/scope check) and `ContextCandidate`'s parent-chunk
|
||||||
|
expansion both derive from the same reranked result set instead of drifting apart.
|
||||||
|
- `src/ragcore/application/models/answer_generator.py` (new) — `OllamaAnswerGenerator`,
|
||||||
|
`AnswerService`'s real generator: resolves the active `GenerationProfile` from
|
||||||
|
`GenerationProfileRegistry` (already built, never previously constructed anywhere) and
|
||||||
|
calls the existing hardened `OllamaGenerationAdapter`. `main.py`'s `lifespan()` now
|
||||||
|
seeds and activates a default local-Ollama answer profile on first boot if none is
|
||||||
|
active, since no seed data ever registered one.
|
||||||
|
- Verified: 10 new unit tests (7 for the pipeline/executors, 3 for the answer generator)
|
||||||
|
pass; `ruff check` clean; `mypy src` clean (the only 4 mypy errors found are in
|
||||||
|
`infrastructure/qdrant/adapter.py`, `infrastructure/qdrant/filters.py`,
|
||||||
|
`api/v1/control/routes.py` — files this change never touched, confirmed via `git
|
||||||
|
status`/`git diff`, pre-existing or from other concurrent work in this checkout).
|
||||||
|
- Full non-integration suite (`pytest -m "not integration"`, excludes tests requiring
|
||||||
|
disposable infra per the repo's own marker convention): **593 passed, 14 failed, 1
|
||||||
|
skipped**. All 14 failures are pre-existing and unrelated to this change: 10 are
|
||||||
|
Windows-only (`psycopg`'s async driver rejects Windows' default `ProactorEventLoop` —
|
||||||
|
`test_health.py`, `test_readiness.py`, `test_control_plane.py`), 2 are Windows-only
|
||||||
|
(`WinError 1314`, no symlink privilege on this account — `test_folder_connector.py`), 1
|
||||||
|
is an unrelated pre-existing git-security-connector assertion, and 1 is the repo's
|
||||||
|
zero-tolerance `test_domain_and_application_respect_dependency_boundaries` test —
|
||||||
|
already red before this change, since `application/ingestion/handler.py` (untouched,
|
||||||
|
commit `bb75f52`) already imports `httpx`/infrastructure modules from the application
|
||||||
|
layer, which that test forbids with no allowlist. This change's new files follow the
|
||||||
|
same established (if already-violating) pattern to reach real adapters, adding more
|
||||||
|
instances of the same pre-existing violation rather than introducing a new kind of one.
|
||||||
|
- **Deployed to production** (`http://192.168.10.150:1237` via `ragcore-app-1`),
|
||||||
|
approved by the user. `scp`'d the 4 changed/new files individually to
|
||||||
|
`/mnt/user/appdata/ragcore/app/...`, `docker compose build app`, verified the built
|
||||||
|
image actually contains the new code (`docker run --rm ragcore-app grep`/`test -f`),
|
||||||
|
then `docker compose up -d --no-deps app` (only the `app` service touched). Startup
|
||||||
|
logs are clean (`ragcore_started environment=production`, no errors); `/health/ready`
|
||||||
|
reports `postgres`/`qdrant`/`storage` all `ok`.
|
||||||
|
- **Live-verified the fix itself**: `POST /v1/search` now returns `401
|
||||||
|
AUTHENTICATION_REQUIRED` for an unauthenticated call, not the old permanent `503
|
||||||
|
SEARCH_UNAVAILABLE` — proof the endpoint reaches real request handling instead of
|
||||||
|
hitting an absent application state, which is exactly the bug this closes.
|
||||||
|
- **Issued a fresh scoped credential** (`answer` scope, application `Fleet Ops`, via the
|
||||||
|
RAGcore admin UI at `rag.itworx.tech` — a separate, working OIDC session, unaffected by
|
||||||
|
the n8n auth problem above) and made a real authenticated `POST /v1/answers` call
|
||||||
|
against the `Fleet Ops Procedures` knowledge space (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`).
|
||||||
|
Got a clean `200`, a real `answer_id`/`retrieval_run_id`, `degraded: false` — but
|
||||||
|
`answerability: not_answerable`, 0 citations, for two different real questions matching
|
||||||
|
real seeded document titles.
|
||||||
|
- **Confirmed this is not a regression from this change**: the same query against the
|
||||||
|
same space through RAGcore's own pre-existing, already-proven Query Lab tool (which
|
||||||
|
uses `PipelineQueryLabExecutor`, code this session never touched) returns the identical
|
||||||
|
`NOT_ANSWERABLE` / 0 evidence / not degraded result — down to `Dense candidates (0)` and
|
||||||
|
`Sparse candidates (0)` at the raw retrieval stage, before fusion or rerank ever runs.
|
||||||
|
Whatever is causing zero matches lives in shared retrieval infrastructure or the data
|
||||||
|
itself, not in the new production executors.
|
||||||
|
- **Ruled out the obvious causes via direct Qdrant/Postgres checks** (not yet root-caused
|
||||||
|
further — flagging as a separate follow-up, not blocking this task): the space
|
||||||
|
genuinely has 83 published, correctly-scoped points in `rag_dense_nomic-embed-text_v1`
|
||||||
|
(right `workspace_id`, right `space_id`, `status: published`, real Dutch-language
|
||||||
|
procedure text); the points' `embedding_model_digest` exactly matches the currently
|
||||||
|
active embedding profile's digest (no stale-embedding mismatch); the collection alias
|
||||||
|
(`rag_dense_nomic-embed-text_active`) correctly resolves to that same collection. One
|
||||||
|
oddity noted in passing, unrelated to retrieval: at least one sampled chunk has
|
||||||
|
`language: "en"` in its payload despite the actual text being Dutch — worth a look if
|
||||||
|
language-filtered queries matter for the demo.
|
||||||
|
- **Decision on `KNOWLEDGE_PROVIDER=ragcore` in Fleet Ops**: still deliberately `demo`.
|
||||||
|
The application is deployed, correctly wired, and behaves identically to RAGcore's own
|
||||||
|
trusted reference implementation — but real questions against real seeded content
|
||||||
|
aren't returning grounded answers yet for a reason that traces to shared
|
||||||
|
retrieval/data, not to this wiring. Flip only after that's root-caused.
|
||||||
|
|
||||||
|
- **n8n workflow 3 (task #86) — incident, recovered; final 2 nodes still blocked on a
|
||||||
|
live n8n auth problem, not a code problem.** Returning to finish the "Summarize sync
|
||||||
|
result" + report-to-Fleet-Ops nodes found the live "Fleet Ops — RAGcore Procedure Sync"
|
||||||
|
workflow's canvas at **zero nodes** — the earlier session's abandoned attempt to call
|
||||||
|
n8n's REST API directly (the "unexplained 401" noted above) had gone through far enough
|
||||||
|
to wipe the live workflow before the 401 stopped it, leaving an empty, unsaved "Current
|
||||||
|
changes" draft on top of the last good save.
|
||||||
|
- **Recovered**: n8n's own Version History (`/workflow/.../history`) still had the last
|
||||||
|
good save, version `260b38b5` (Aug 4, 21:08), with all 4 real nodes intact (Schedule
|
||||||
|
Trigger -> List procedures -> Prepare uploads -> Upload to RAGcore) plus the build
|
||||||
|
sticky note. Used the history panel's own **Restore version** action (not a manual
|
||||||
|
rebuild) to bring the live workflow back to that exact state. Confirmed via the page's
|
||||||
|
DOM (`[data-test-id="canvas-node"]` count) both before (0) and after (4) — the canvas
|
||||||
|
itself render fully off-screen (nodes positioned at negative Y coordinates, a separate
|
||||||
|
display-only vue-flow pan bug worked around by directly setting the transform pane's
|
||||||
|
CSS transform; harmless, doesn't touch saved data).
|
||||||
|
- **Then blocked again attempting the 2 new nodes**: adding a Code node via the UI's own
|
||||||
|
"What happens next?" panel (a real, first-party n8n interaction, not the REST API)
|
||||||
|
immediately surfaced n8n's own autosave toast: **"Problem saving workflow — Autosave
|
||||||
|
failed: Unauthorized."** A full sign-out/sign-in cycle right before this (fresh
|
||||||
|
credentials, fresh page load) did not fix it — immediately after a successful
|
||||||
|
interactive "Sign in", `fetch('/rest/workflows/...')` from that same authenticated tab
|
||||||
|
still returned 401. This is not the ordinary "your session expired, log in again"
|
||||||
|
friction seen earlier in the session; it's the live n8n instance's own save path
|
||||||
|
rejecting a request made moments after a successful login, which is exactly the
|
||||||
|
failure mode that caused the wipe above. Continuing to add nodes under this condition
|
||||||
|
risks losing work again with no guarantee the next failure is as recoverable, so this
|
||||||
|
was stopped deliberately rather than retried. The workflow was left in its safely
|
||||||
|
restored, 4-node, unpublished state — verified via the DOM node count immediately
|
||||||
|
before closing the session — nothing was added or changed beyond the restore.
|
||||||
|
- **Not a code/workflow-design problem**: the 2 remaining nodes (a Code node
|
||||||
|
summarizing the sync result, and an HTTP node reporting to Fleet Ops's already-live
|
||||||
|
`POST /api/v1/integrations/n8n/procedures-sync-result`) were never built — this
|
||||||
|
blocked before any node configuration happened. The design itself (mirroring WF2/WF4's
|
||||||
|
existing report-result pattern) is unchanged from earlier planning.
|
||||||
|
|
||||||
|
- **n8n workflow 3 — root cause found and fixed; both remaining nodes now built and
|
||||||
|
saved.** The user pushed back on the "auth problem" framing (n8n visibly showed signed
|
||||||
|
in), which was the right call — dug into the live n8n container's own logs
|
||||||
|
(`docker logs n8n`) rather than continuing to guess from the browser side, and found the
|
||||||
|
real cause: `browserId check failed on /rest/workflows/:workflowId`, alongside an
|
||||||
|
express-rate-limit warning about `X-Forwarded-For` being present while Express `trust
|
||||||
|
proxy` is `false`. n8n's Unraid template
|
||||||
|
(`/boot/config/plugins/dockerMan/templates-user/my-n8n.xml`) had `N8N_PROXY_HOPS=0`
|
||||||
|
despite genuinely running behind the TLS-terminating reverse proxy at
|
||||||
|
`n8n.itworx.tech` — with proxy trust disabled, n8n couldn't correctly resolve the
|
||||||
|
request as HTTPS, which broke its CSRF-style browserId cookie check on every
|
||||||
|
workflow-mutating REST call (autosave, but not plain page loads, which explains why the
|
||||||
|
UI looked fully logged in the whole time).
|
||||||
|
- **Fix, approved by the user beforehand** (shared instance, brief restart): backed up
|
||||||
|
the template to a timestamped `.bak`, changed `N8N_PROXY_HOPS` `0` -> `1`, then
|
||||||
|
recreated the `n8n` container on the Unraid host (stop, rename to
|
||||||
|
`n8n_pre_proxyhops_fix` as an instant rollback, `docker run` with every existing env
|
||||||
|
var/volume/port/label preserved exactly plus the one corrected value). All 3
|
||||||
|
previously-active workflows (Vehicle Return Orchestration, Scheduled Data Quality
|
||||||
|
Scan, Workflow Error Handler) re-activated cleanly on the new container — nothing
|
||||||
|
lost. Verified the fix by reproducing the exact save action that used to fail
|
||||||
|
(adding a node via n8n's own UI): no more "Unauthorized" toast, and the added node
|
||||||
|
survived a full page reload. `docker logs n8n` shows zero `browserId`/401 entries
|
||||||
|
since the fix.
|
||||||
|
- **Mid-fix-verification, a real node got deleted by an errant `Ctrl+A`** (browser
|
||||||
|
focus landed on the canvas instead of a text field during cleanup, selecting and then
|
||||||
|
deleting the "List procedures" node). Caught it via a node-count check, used n8n's own
|
||||||
|
Version History **Restore version** to the last good save (not a manual rebuild), then
|
||||||
|
redid the 2 new nodes carefully (verifying focus before every `Ctrl+A` this time). No
|
||||||
|
data lost, just redone.
|
||||||
|
- **Built**: `Summarize sync result` (Code node, JS) — counts `synced`/`failed` from
|
||||||
|
`$input.all()` by checking each item's `json.error` (matches "Upload to RAGcore"'s own
|
||||||
|
`On Error: Continue` setting, confirmed by inspecting that node's Settings tab, so
|
||||||
|
failed uploads land in the same output stream with an `error` field rather than a
|
||||||
|
separate branch). `Report sync result to Fleet Ops` (HTTP Request, POST) — targets the
|
||||||
|
already-live `/api/v1/integrations/n8n/procedures-sync-result`, reusing the existing
|
||||||
|
"Fleet Ops Service Token" Header Auth credential (same one WF4 already uses against
|
||||||
|
this same backend), JSON body `{execution_id, synced, failed}` matching
|
||||||
|
`ProcedureSyncResultIn` exactly.
|
||||||
|
- **Saved, not published.** All 6 nodes (Schedule Trigger -> List procedures -> Prepare
|
||||||
|
uploads -> Upload to RAGcore -> Summarize sync result -> Report sync result to Fleet
|
||||||
|
Ops) confirmed present after a full page reload. The Schedule Trigger is configured
|
||||||
|
for once daily at midnight -- publishing activates real, live, unattended runs against
|
||||||
|
production RAGcore and Fleet Ops, so this was deliberately left for a separate,
|
||||||
|
explicit approval rather than done automatically.
|
||||||
|
|
||||||
|
- **Exact next action**: (1) RAGcore search/answer wiring (`a2905cc`) is deployed and
|
||||||
|
proven correctly wired (matches Query Lab's trusted behavior exactly) — the open item is
|
||||||
|
now root-causing why `Fleet Ops Procedures` returns zero dense/sparse candidates for
|
||||||
|
real questions despite having genuinely matching, correctly-scoped, correctly-embedded
|
||||||
|
indexed content (see evidence above); this is retrieval/data, not application wiring.
|
||||||
|
Only flip `KNOWLEDGE_PROVIDER=ragcore` once that's fixed and a real grounded answer comes
|
||||||
|
back. (2) n8n workflow 3 is fully built and saved (all 6 nodes) but **not published** —
|
||||||
|
decide whether to publish it (starts real daily runs against production) and, once
|
||||||
|
live-verified end to end, add its file to `n8n/workflows/` and `MANIFEST.md` as the
|
||||||
|
source of truth alongside the other 3 workflows. (3) MCP Hub: registry push + production
|
||||||
|
deployment of the now-committed connector remain separate, explicitly-gated checkpoints.
|
||||||
|
WF4's own timeout/retry gap remains open, deferred, non-blocking.
|
||||||
|
|
||||||
|
## Branch push and Unraid redeploy (2026-08-05)
|
||||||
|
|
||||||
|
- Pushed `feat/live-n8n-ragcore-integration` to `origin` (Gitea on the same Unraid host),
|
||||||
|
now tracking `origin/feat/live-n8n-ragcore-integration` (`0571a40`).
|
||||||
|
- **Redeployed the live Fleet Ops instance** (`http://192.168.10.150:1236`) from this
|
||||||
|
branch, bringing it from the previously-deployed `0da5251` up to `0571a40` — 5 commits,
|
||||||
|
the meaningful one being the `RAGcoreKnowledgeProvider` rewrite (`e5d8466`); the rest
|
||||||
|
are `PROJECT_STATE.md`-only evidence commits.
|
||||||
|
- Followed the deployment directory's own established archive convention
|
||||||
|
(`deploy/unraid/README.md`: "deployed from a committed source archive"): `git archive`
|
||||||
|
of `HEAD` as `source-0571a40.tar.gz`, matching the naming pattern of the existing
|
||||||
|
archives already in `/mnt/user/appdata/mobilityops/.deploy/`; `scp`'d it there;
|
||||||
|
extracted over the deployment directory excluding `.env` and `.deploy` itself (so the
|
||||||
|
live secrets file and archive history were never touched); updated
|
||||||
|
`.deploy/source-revision` to the new full commit hash, matching its existing format.
|
||||||
|
- `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db
|
||||||
|
api web` — `api` image rebuilt and the container recreated (picked up the new code);
|
||||||
|
`web` rebuilt too but didn't need recreating (no frontend changes this branch); `db`
|
||||||
|
untouched. Migrations ran automatically in the `api` entrypoint with no errors.
|
||||||
|
- **Verified live**: `/health` returns `200` from inside the `api` container; the new
|
||||||
|
`/api/v1/integrations/n8n/procedures` endpoint (added this branch, needed for n8n
|
||||||
|
workflow 3) is reachable through the public web proxy and correctly enforces auth
|
||||||
|
(`422`, missing `X-Service-Token`, not a `404` — proves the route exists and is live,
|
||||||
|
not just that the proxy responds). `KNOWLEDGE_PROVIDER=demo` confirmed unchanged in the
|
||||||
|
live `.env` (only extracted new source files, never touched it) — the demo Knowledge
|
||||||
|
Assistant is still what's live, exactly as intended.
|
||||||
|
- No seed reset run (this was an update to a running instance with real accumulated
|
||||||
|
demo data, not the initial deploy — re-seeding would have been destructive and wasn't
|
||||||
|
warranted by anything in this branch's changes).
|
||||||
|
- **Not done**: no PR opened/merged to `master` — the user asked for commit, push, and
|
||||||
|
redeploy, not a merge; `master` is untouched and still 19 commits behind this branch.
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -114,9 +124,9 @@ All defaults are configurable via `.env` (see `.env.example`).
|
|||||||
## Quality gates
|
## Quality gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # backend: pytest (127 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 (56 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,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,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')
|
||||||
@@ -89,6 +89,8 @@ def preview_return(
|
|||||||
resulting_odometer_km=evaluation.resulting_odometer_km,
|
resulting_odometer_km=evaluation.resulting_odometer_km,
|
||||||
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
||||||
status_reason=evaluation.status_reason,
|
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,
|
would_create_quality_issue=evaluation.would_create_quality_issue,
|
||||||
attention_reasons=evaluation.attention_reasons,
|
attention_reasons=evaluation.attention_reasons,
|
||||||
next_booking_risk=(
|
next_booking_risk=(
|
||||||
|
|||||||
@@ -108,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.data_quality import DataQualityIssue
|
|||||||
from app.models.inspection import Inspection
|
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,
|
ApplyRecommendedStatusResult,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DataQualityIssueDetailOut,
|
DataQualityIssueDetailOut,
|
||||||
@@ -21,11 +22,14 @@ from app.schemas import (
|
|||||||
ResolveOdometerRegressionRequest,
|
ResolveOdometerRegressionRequest,
|
||||||
ResolveOverlapRequest,
|
ResolveOverlapRequest,
|
||||||
ScanResultOut,
|
ScanResultOut,
|
||||||
|
StatusRecommendationOut,
|
||||||
|
VehicleStatusFactsOut,
|
||||||
)
|
)
|
||||||
from app.services.data_quality import (
|
from app.services.data_quality import (
|
||||||
apply_recommended_status,
|
apply_recommended_status,
|
||||||
defer_issue,
|
defer_issue,
|
||||||
merge_customers,
|
merge_customers,
|
||||||
|
preview_vehicle_status_recommendation,
|
||||||
provide_missing_fields,
|
provide_missing_fields,
|
||||||
reject_issue,
|
reject_issue,
|
||||||
resolve_booking_overlap,
|
resolve_booking_overlap,
|
||||||
@@ -241,17 +245,43 @@ def resolve_overlap(
|
|||||||
return _to_out(issue)
|
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(
|
@router.post(
|
||||||
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
||||||
)
|
)
|
||||||
def apply_status(
|
def apply_status(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
|
body: ApplyRecommendedStatusRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(require_operations_manager),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> ApplyRecommendedStatusResult:
|
) -> ApplyRecommendedStatusResult:
|
||||||
issue, applied_status, reason = apply_recommended_status(db, public_ref, user)
|
issue, applied_status, reason_code = apply_recommended_status(
|
||||||
|
db, public_ref, user, body.recommendation_token
|
||||||
|
)
|
||||||
return ApplyRecommendedStatusResult(
|
return ApplyRecommendedStatusResult(
|
||||||
issue=_to_out(issue), applied_status=applied_status, reason=reason
|
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +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 ScanResultOut
|
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.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()
|
||||||
@@ -89,3 +99,124 @@ def scheduled_scan(
|
|||||||
|
|
||||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||||
return ScanResultOut(created=result.created)
|
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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,53 +12,81 @@ from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||||
|
|
||||||
# Static application sections. Manager-only sections are filtered by role, mirroring the
|
# Static application sections. `id` is a stable code matching navigation.json's
|
||||||
# same nav visibility rule Layout.tsx applies -- search must never surface a destination
|
# `items.*` keys -- the frontend localizes both the section label and its one-line
|
||||||
# the current role can't actually reach.
|
# 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] = [
|
_SECTIONS: list[dict] = [
|
||||||
{
|
{
|
||||||
"label": "Overview",
|
"id": "overview",
|
||||||
"detail": "Operations dashboard",
|
|
||||||
"link": "/dashboard",
|
"link": "/dashboard",
|
||||||
"terms": ["overview", "dashboard", "readiness"],
|
# 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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Fleet",
|
"id": "fleet",
|
||||||
"detail": "Vehicle registry",
|
|
||||||
"link": "/vehicles",
|
"link": "/vehicles",
|
||||||
"terms": ["fleet", "vehicle", "vehicles"],
|
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Bookings",
|
"id": "bookings",
|
||||||
"detail": "Rental bookings",
|
|
||||||
"link": "/bookings",
|
"link": "/bookings",
|
||||||
"terms": ["booking", "bookings", "rental"],
|
"terms": [
|
||||||
|
"booking",
|
||||||
|
"bookings",
|
||||||
|
"rental",
|
||||||
|
"boeking",
|
||||||
|
"boekingen",
|
||||||
|
"verhuur",
|
||||||
|
"réservation",
|
||||||
|
"réservations",
|
||||||
|
"location",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Data quality",
|
"id": "quality",
|
||||||
"detail": "Quality workbench",
|
|
||||||
"link": "/data-quality",
|
"link": "/data-quality",
|
||||||
"terms": ["quality", "data quality", "issues"],
|
"terms": [
|
||||||
|
"quality",
|
||||||
|
"data quality",
|
||||||
|
"issues",
|
||||||
|
"kwaliteit",
|
||||||
|
"datakwaliteit",
|
||||||
|
"problemen",
|
||||||
|
"qualité",
|
||||||
|
"problèmes",
|
||||||
|
],
|
||||||
"role": "operations_manager",
|
"role": "operations_manager",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Knowledge",
|
"id": "knowledge",
|
||||||
"detail": "Procedure assistant",
|
|
||||||
"link": "/knowledge",
|
"link": "/knowledge",
|
||||||
"terms": ["knowledge", "procedures"],
|
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Integrations",
|
"id": "integrations",
|
||||||
"detail": "Automation and integration status",
|
|
||||||
"link": "/automation",
|
"link": "/automation",
|
||||||
"terms": ["automation", "integrations", "systems", "n8n"],
|
"terms": [
|
||||||
|
"automation",
|
||||||
|
"integrations",
|
||||||
|
"systems",
|
||||||
|
"n8n",
|
||||||
|
"automatisering",
|
||||||
|
"integraties",
|
||||||
|
"systemen",
|
||||||
|
"automatisation",
|
||||||
|
"intégrations",
|
||||||
|
"systèmes",
|
||||||
|
],
|
||||||
"role": "operations_manager",
|
"role": "operations_manager",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Audit trail",
|
"id": "audit",
|
||||||
"detail": "Audit history",
|
|
||||||
"link": "/audit",
|
"link": "/audit",
|
||||||
"terms": ["audit", "history"],
|
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||||
"role": "operations_manager",
|
"role": "operations_manager",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -83,8 +111,8 @@ def search(
|
|||||||
results.append(
|
results.append(
|
||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="section",
|
type="section",
|
||||||
label=section["label"],
|
label=section["id"],
|
||||||
detail=section["detail"],
|
detail_code=section["id"],
|
||||||
link=section["link"],
|
link=section["link"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -108,7 +136,8 @@ def search(
|
|||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="vehicle",
|
type="vehicle",
|
||||||
label=v.public_ref,
|
label=v.public_ref,
|
||||||
detail=f"{v.make} {v.model} · {v.location}",
|
detail_code="vehicleSummary",
|
||||||
|
detail_params={"make": v.make, "model": v.model, "location": v.location},
|
||||||
link=f"/vehicles/{v.public_ref}",
|
link=f"/vehicles/{v.public_ref}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -120,7 +149,7 @@ def search(
|
|||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="booking",
|
type="booking",
|
||||||
label=b.public_ref,
|
label=b.public_ref,
|
||||||
detail=b.status,
|
detail_code=b.status,
|
||||||
link=f"/bookings/{b.public_ref}",
|
link=f"/bookings/{b.public_ref}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -138,7 +167,7 @@ def search(
|
|||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="data_quality_issue",
|
type="data_quality_issue",
|
||||||
label=i.public_ref,
|
label=i.public_ref,
|
||||||
detail=i.rule_type.replace("_", " "),
|
detail_code=i.rule_type,
|
||||||
link=f"/data-quality/{i.public_ref}",
|
link=f"/data-quality/{i.public_ref}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,8 +21,10 @@ 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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.api.routers import (
|
|||||||
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
|
||||||
|
|
||||||
@@ -33,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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class ReturnPreviewResult(BaseModel):
|
|||||||
resulting_odometer_km: int
|
resulting_odometer_km: int
|
||||||
resulting_vehicle_status: str
|
resulting_vehicle_status: str
|
||||||
status_reason: str
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int] = {}
|
||||||
would_create_quality_issue: bool
|
would_create_quality_issue: bool
|
||||||
attention_reasons: list[str]
|
attention_reasons: list[str]
|
||||||
next_booking_risk: NextBookingRisk | None
|
next_booking_risk: NextBookingRisk | None
|
||||||
@@ -141,6 +143,53 @@ 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):
|
class ProvideFieldsRequest(BaseModel):
|
||||||
fields: dict[str, str]
|
fields: dict[str, str]
|
||||||
|
|
||||||
@@ -157,16 +206,41 @@ class ResolveOverlapRequest(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
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):
|
class ApplyRecommendedStatusResult(BaseModel):
|
||||||
issue: DataQualityIssueOut
|
issue: DataQualityIssueOut
|
||||||
applied_status: str
|
applied_status: str
|
||||||
reason: str
|
reason_code: str
|
||||||
|
|
||||||
|
|
||||||
class SearchResultItem(BaseModel):
|
class SearchResultItem(BaseModel):
|
||||||
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||||
label: str
|
label: str
|
||||||
detail: str
|
detail_code: str
|
||||||
|
detail_params: dict[str, str] = {}
|
||||||
link: str
|
link: str
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +249,18 @@ class SearchResponse(BaseModel):
|
|||||||
results: list[SearchResultItem]
|
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):
|
class N8nIntegrationStatus(BaseModel):
|
||||||
configured: bool
|
configured: bool
|
||||||
dispatch_enabled: bool
|
dispatch_enabled: bool
|
||||||
@@ -185,6 +271,10 @@ class N8nIntegrationStatus(BaseModel):
|
|||||||
succeeded: int
|
succeeded: int
|
||||||
latest_success_at: datetime | None
|
latest_success_at: datetime | None
|
||||||
latest_failure_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):
|
class McpHubIntegrationStatus(BaseModel):
|
||||||
@@ -269,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import csv
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, date, datetime, timedelta
|
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
|
||||||
@@ -108,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.
|
||||||
@@ -223,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(),
|
||||||
@@ -240,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,
|
||||||
@@ -287,6 +321,9 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ from app.models.data_quality import DataQualityIssue
|
|||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
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,6 +76,7 @@ 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
|
||||||
@@ -87,10 +95,14 @@ def _open_issue(
|
|||||||
)
|
)
|
||||||
.order_by(DataQualityIssue.detected_at.desc())
|
.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 = {
|
evidence: dict = {
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"entity_ref": entity_ref,
|
"entity_ref": entity_ref,
|
||||||
"related_refs": related_refs,
|
"related_refs": related_refs,
|
||||||
|
"signals": signals or [],
|
||||||
}
|
}
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
evidence["reopened_from"] = previous.public_ref
|
evidence["reopened_from"] = previous.public_ref
|
||||||
@@ -121,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(
|
||||||
@@ -146,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -170,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():
|
||||||
@@ -185,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],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -213,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:
|
||||||
@@ -295,6 +314,17 @@ 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
|
||||||
|
|
||||||
@@ -650,28 +680,7 @@ def resolve_booking_overlap(
|
|||||||
return issue
|
return issue
|
||||||
|
|
||||||
|
|
||||||
def _recommend_vehicle_status(
|
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||||
operational_status: str, has_active_booking: bool, has_open_high_issue: bool
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
"""The single authoritative recommendation function for vehicle_status_conflict,
|
|
||||||
mirroring the exact conditions `_scan_vehicle_status_conflicts` flags."""
|
|
||||||
if operational_status == "available" and has_active_booking:
|
|
||||||
return "rented", "An active booking exists; the vehicle should be marked rented."
|
|
||||||
if operational_status == "rented" and not has_active_booking:
|
|
||||||
return "available", "No active booking exists; the vehicle should be marked available."
|
|
||||||
if operational_status == "available" and has_open_high_issue:
|
|
||||||
return "blocked", "A high-severity quality issue is open; the vehicle should be blocked."
|
|
||||||
if operational_status == "maintenance" and has_active_booking:
|
|
||||||
return (
|
|
||||||
"rented",
|
|
||||||
"An active booking exists despite the maintenance status; it should be rented.",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def apply_recommended_status(
|
|
||||||
db: Session, public_ref: str, actor: CurrentUser
|
|
||||||
) -> tuple[DataQualityIssue, str, str]:
|
|
||||||
issue = _load_open_issue(db, public_ref)
|
issue = _load_open_issue(db, public_ref)
|
||||||
if issue.rule_type != "vehicle_status_conflict":
|
if issue.rule_type != "vehicle_status_conflict":
|
||||||
raise AppError(
|
raise AppError(
|
||||||
@@ -679,47 +688,77 @@ def apply_recommended_status(
|
|||||||
"This issue is not a vehicle_status_conflict issue.",
|
"This issue is not a vehicle_status_conflict issue.",
|
||||||
status_code=409,
|
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())
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
raise AppError(
|
raise AppError(
|
||||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
)
|
)
|
||||||
|
|
||||||
has_active_booking = (
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
db.scalar(
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
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,
|
||||||
)
|
)
|
||||||
is not None
|
if recommendation.manual_review_required or not recommendation.safe_to_apply:
|
||||||
)
|
raise AppError(
|
||||||
has_open_high_issue = (
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
db.scalar(
|
"This vehicle's state requires manual review; no automatic status change is safe.",
|
||||||
select(DataQualityIssue.id).where(
|
status_code=409,
|
||||||
DataQualityIssue.entity_type == "vehicle",
|
|
||||||
DataQualityIssue.entity_id == vehicle.id,
|
|
||||||
DataQualityIssue.status == "open",
|
|
||||||
DataQualityIssue.severity == "high",
|
|
||||||
DataQualityIssue.id != issue.id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not None
|
if recommendation.recommended_status is None:
|
||||||
)
|
|
||||||
recommendation = _recommend_vehicle_status(
|
|
||||||
vehicle.operational_status, has_active_booking, has_open_high_issue
|
|
||||||
)
|
|
||||||
if recommendation is None:
|
|
||||||
raise AppError(
|
raise AppError(
|
||||||
"NO_CONFLICT_DETECTED",
|
"NO_CONFLICT_DETECTED",
|
||||||
"The current vehicle state no longer conflicts; nothing to apply.",
|
"The current vehicle state no longer conflicts; nothing to apply.",
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
new_status, reason = recommendation
|
new_status = recommendation.recommended_status
|
||||||
|
reason_code = recommendation.recommendation_code
|
||||||
|
|
||||||
before = {"operational_status": vehicle.operational_status}
|
before = {"operational_status": vehicle.operational_status}
|
||||||
vehicle.operational_status = new_status
|
vehicle.operational_status = new_status
|
||||||
vehicle.version += 1
|
vehicle.version += 1
|
||||||
|
|
||||||
# Re-validate: the same recommendation function must find no further conflict.
|
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
|
||||||
if _recommend_vehicle_status(new_status, has_active_booking, has_open_high_issue) is not None:
|
# 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(
|
raise AppError(
|
||||||
"CONFLICT_STILL_PRESENT",
|
"CONFLICT_STILL_PRESENT",
|
||||||
"Applying the recommended status did not resolve the conflict.",
|
"Applying the recommended status did not resolve the conflict.",
|
||||||
@@ -737,7 +776,7 @@ def apply_recommended_status(
|
|||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
before=before,
|
before=before,
|
||||||
after={"operational_status": vehicle.operational_status},
|
after={"operational_status": vehicle.operational_status},
|
||||||
metadata={"issue_ref": issue.public_ref, "reason": reason},
|
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
|
||||||
)
|
)
|
||||||
|
|
||||||
issue.status = "resolved"
|
issue.status = "resolved"
|
||||||
@@ -755,7 +794,7 @@ def apply_recommended_status(
|
|||||||
after={"status": "resolved"},
|
after={"status": "resolved"},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return issue, new_status, reason
|
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")
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
|
|||||||
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
||||||
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
||||||
)[:2000]
|
)[:2000]
|
||||||
|
row.last_error_code = "staleLeaseRecovered"
|
||||||
db.commit()
|
db.commit()
|
||||||
return len(rows)
|
return len(rows)
|
||||||
finally:
|
finally:
|
||||||
@@ -111,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()
|
||||||
@@ -138,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
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ from sqlalchemy import func, select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import N8nIntegrationStatus
|
from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence
|
||||||
|
|
||||||
settings = get_settings()
|
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:
|
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||||
counts: dict[str, int] = dict(
|
counts: dict[str, int] = dict(
|
||||||
@@ -42,6 +52,52 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
|||||||
else:
|
else:
|
||||||
state = "no_evidence"
|
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(
|
return N8nIntegrationStatus(
|
||||||
configured=bool(settings.n8n_webhook_url),
|
configured=bool(settings.n8n_webhook_url),
|
||||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||||
@@ -52,4 +108,12 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
|||||||
succeeded=succeeded,
|
succeeded=succeeded,
|
||||||
latest_success_at=latest_success_at,
|
latest_success_at=latest_success_at,
|
||||||
latest_failure_at=latest_failure_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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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
|
||||||
|
|
||||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
DEFAULT_LANGUAGE = "en-GB"
|
DEFAULT_LANGUAGE = "en-GB"
|
||||||
@@ -35,7 +36,11 @@ STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_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:
|
||||||
@@ -70,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"
|
||||||
@@ -110,7 +98,7 @@ 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),
|
||||||
@@ -218,17 +206,28 @@ class DemoKnowledgeProvider:
|
|||||||
def _score(
|
def _score(
|
||||||
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
||||||
) -> 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:
|
||||||
token_idf = idf.get(token, 0.0)
|
token_idf = idf.get(token, 0.0)
|
||||||
if token_idf == 0.0:
|
if token_idf == 0.0:
|
||||||
continue
|
continue
|
||||||
if token in section.heading_tokens:
|
if token in section.body_tokens:
|
||||||
score += 3 * token_idf
|
score += 3 * token_idf
|
||||||
elif token in section.document.title_tokens:
|
elif token in section.heading_tokens:
|
||||||
score += 2 * token_idf
|
score += 2 * token_idf
|
||||||
elif token in section.body_tokens:
|
elif token in section.document.title_tokens:
|
||||||
score += token_idf
|
score += 1.5 * token_idf
|
||||||
return score
|
return score
|
||||||
|
|
||||||
def ask(
|
def ask(
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -35,11 +46,15 @@ class RAGcoreKnowledgeProvider:
|
|||||||
def health(self, language: str = "en-GB") -> 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,51 +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, language: str = "en-GB") -> 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,
|
|
||||||
"language": language,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|||||||
@@ -28,19 +28,41 @@ def _next_public_ref(db: Session) -> str:
|
|||||||
|
|
||||||
def _derive_vehicle_status_with_reason(
|
def _derive_vehicle_status_with_reason(
|
||||||
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
||||||
) -> tuple[str, str]:
|
) -> 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:
|
if body.damage_reported and body.technical_warning:
|
||||||
return "blocked", "Damage and a technical warning were both reported on return."
|
return (
|
||||||
|
"blocked",
|
||||||
|
"returnBlockedDamageAndTechnical",
|
||||||
|
{},
|
||||||
|
)
|
||||||
if body.damage_reported:
|
if body.damage_reported:
|
||||||
return "blocked", "Damage was reported on return."
|
return "blocked", "returnBlockedDamage", {}
|
||||||
if body.technical_warning:
|
if body.technical_warning:
|
||||||
return "blocked", "A technical warning was reported on return."
|
return "blocked", "returnBlockedTechnicalWarning", {}
|
||||||
if new_odometer >= vehicle.next_service_km:
|
if new_odometer >= vehicle.next_service_km:
|
||||||
return (
|
return (
|
||||||
"maintenance",
|
"maintenance",
|
||||||
f"Odometer reached the {vehicle.next_service_km:,} km service threshold.",
|
"returnServiceThresholdReached",
|
||||||
|
{"threshold_km": vehicle.next_service_km},
|
||||||
)
|
)
|
||||||
return "cleaning", "No damage, technical warning or service threshold; routed to cleaning."
|
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
|
@dataclass
|
||||||
@@ -51,6 +73,8 @@ class ReturnEvaluation:
|
|||||||
resulting_odometer_km: int
|
resulting_odometer_km: int
|
||||||
resulting_vehicle_status: str
|
resulting_vehicle_status: str
|
||||||
status_reason: str
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int]
|
||||||
would_create_quality_issue: bool
|
would_create_quality_issue: bool
|
||||||
attention_reasons: list[str]
|
attention_reasons: list[str]
|
||||||
next_booking_risk: dict | None
|
next_booking_risk: dict | None
|
||||||
@@ -64,9 +88,10 @@ def evaluate_return(
|
|||||||
preview and commit can never drift apart."""
|
preview and commit can never drift apart."""
|
||||||
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||||
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
||||||
resulting_status, status_reason = _derive_vehicle_status_with_reason(
|
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
|
||||||
body, vehicle, resulting_odometer_km
|
body, vehicle, resulting_odometer_km
|
||||||
)
|
)
|
||||||
|
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
|
||||||
|
|
||||||
attention_reasons = []
|
attention_reasons = []
|
||||||
if body.damage_reported:
|
if body.damage_reported:
|
||||||
@@ -101,6 +126,8 @@ def evaluate_return(
|
|||||||
resulting_odometer_km=resulting_odometer_km,
|
resulting_odometer_km=resulting_odometer_km,
|
||||||
resulting_vehicle_status=resulting_status,
|
resulting_vehicle_status=resulting_status,
|
||||||
status_reason=status_reason,
|
status_reason=status_reason,
|
||||||
|
status_reason_code=status_reason_code,
|
||||||
|
status_reason_params=status_reason_params,
|
||||||
would_create_quality_issue=odometer_regression,
|
would_create_quality_issue=odometer_regression,
|
||||||
attention_reasons=attention_reasons,
|
attention_reasons=attention_reasons,
|
||||||
next_booking_risk=next_booking_risk,
|
next_booking_risk=next_booking_risk,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -249,28 +249,74 @@ def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
|
|||||||
assert booking["status"] == "blocked"
|
assert booking["status"] == "blocked"
|
||||||
|
|
||||||
|
|
||||||
def test_apply_recommended_status_requires_operations_manager(employee_client):
|
def test_status_recommendation_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status"
|
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/status-recommendation"
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
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):
|
def test_apply_recommended_status_resolves_conflict(ops_client):
|
||||||
target = _first_open(ops_client, "vehicle_status_conflict")
|
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(
|
response = ops_client.post(
|
||||||
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status"
|
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": preview["recommendation_token"]},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["issue"]["status"] == "resolved"
|
assert body["issue"]["status"] == "resolved"
|
||||||
assert body["applied_status"]
|
assert body["applied_status"] == preview["recommended_status"]
|
||||||
assert body["reason"]
|
assert body["reason_code"] == preview["recommendation_code"]
|
||||||
|
|
||||||
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
assert vehicle["operational_status"] == body["applied_status"]
|
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):
|
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
||||||
@@ -365,6 +411,95 @@ def test_manual_scan_records_audit_event(ops_client):
|
|||||||
assert "created" in events[0]["metadata"]
|
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):
|
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
|
||||||
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
|
# 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
|
# next scan re-detects the same unresolved condition -- it must not silently vanish
|
||||||
|
|||||||
@@ -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,6 +188,7 @@ 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_claim_sets_a_lease_deadline():
|
def test_claim_sets_a_lease_deadline():
|
||||||
@@ -208,6 +238,7 @@ def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
|
|||||||
assert event.next_attempt_at is None
|
assert event.next_attempt_at is None
|
||||||
assert event.attempts == 2
|
assert event.attempts == 2
|
||||||
assert "stale" in event.last_error.lower()
|
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.
|
# The reclaimed event is now a normal pending event, immediately claimable again.
|
||||||
claimed = dispatcher._claim_due_events()
|
claimed = dispatcher._claim_due_events()
|
||||||
@@ -225,7 +256,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
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": {}},
|
||||||
@@ -241,7 +272,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
|
|||||||
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
def test_run_dispatch_cycle_end_to_end(monkeypatch):
|
||||||
event_id = _make_pending_event("MO-005")
|
event_id = _make_pending_event("MO-005")
|
||||||
|
|
||||||
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": {}},
|
||||||
|
|||||||
@@ -39,3 +39,82 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
|
|||||||
body = response.json()["n8n"]
|
body = response.json()["n8n"]
|
||||||
assert body["failed"] == 0
|
assert body["failed"] == 0
|
||||||
assert body["state"] == "operational"
|
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
|
||||||
|
|||||||
@@ -122,3 +122,138 @@ def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
|||||||
assert first.status_code == 200
|
assert first.status_code == 200
|
||||||
assert second.status_code == 200
|
assert second.status_code == 200
|
||||||
assert second.json()["created"] == {}
|
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(
|
||||||
@@ -118,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"
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ def test_search_finds_a_vehicle_by_reference(ops_client):
|
|||||||
assert match is not None
|
assert match is not None
|
||||||
assert match["label"] == "MO-001"
|
assert match["label"] == "MO-001"
|
||||||
assert match["link"] == "/vehicles/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):
|
def test_search_finds_a_booking_by_reference(ops_client):
|
||||||
@@ -37,7 +41,21 @@ def test_search_never_returns_data_quality_issues_for_rental_employee(employee_c
|
|||||||
|
|
||||||
def test_search_section_result_visible_to_operations_manager(ops_client):
|
def test_search_section_result_visible_to_operations_manager(ops_client):
|
||||||
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||||
assert any(r["type"] == "section" and r["link"] == "/audit" for r in result["results"])
|
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):
|
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
||||||
|
|||||||
@@ -23,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:
|
||||||
@@ -138,6 +142,7 @@ def test_seed_scenario_s5_failed_workflow_run():
|
|||||||
assert failed.delivery_status == "failed"
|
assert failed.delivery_status == "failed"
|
||||||
assert failed.attempts >= 1
|
assert failed.attempts >= 1
|
||||||
assert failed.last_error
|
assert failed.last_error
|
||||||
|
assert failed.last_error_code == "connectionError"
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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)
|
||||||
@@ -31,7 +31,9 @@ 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_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -181,26 +184,67 @@ paths:
|
|||||||
description: Wrong rule type, issue not open, or overlap still present
|
description: Wrong rule type, issue not open, or overlap still present
|
||||||
'422':
|
'422':
|
||||||
description: booking_ref not one of the overlapping bookings
|
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:
|
/api/v1/data-quality/issues/{public_ref}/apply-recommended-status:
|
||||||
post:
|
post:
|
||||||
operationId: applyRecommendedVehicleStatus
|
operationId: applyRecommendedVehicleStatus
|
||||||
description: >-
|
description: >-
|
||||||
vehicle_status_conflict only. Applies the one authoritative recommendation
|
vehicle_status_conflict only. Applies the one authoritative recommendation
|
||||||
function's output and re-validates before resolving.
|
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:
|
parameters:
|
||||||
- $ref: '#/components/parameters/PublicRef'
|
- $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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Applied status, reason and the resolved issue
|
description: Applied status, reason code and the resolved issue
|
||||||
'409':
|
'409':
|
||||||
description: Wrong rule type, issue not open, or no conflict detected
|
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:
|
/api/v1/search:
|
||||||
get:
|
get:
|
||||||
operationId: search
|
operationId: search
|
||||||
description: >-
|
description: >-
|
||||||
Bounded typed results (vehicle, booking, data_quality_issue, section).
|
Bounded typed results (vehicle, booking, data_quality_issue, section).
|
||||||
Data-quality and manager-only sections are filtered server-side by role.
|
Data-quality and manager-only sections are filtered server-side by role.
|
||||||
Customers are never returned -- no customer detail route exists.
|
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:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
name: q
|
name: q
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -eu
|
|||||||
|
|
||||||
container_name="${1:-n8n}"
|
container_name="${1:-n8n}"
|
||||||
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
|
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
|
||||||
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}"
|
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.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 -- the
|
||||||
if [ -z "$callback_token" ]; then
|
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
|
||||||
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
|
# Token"), which must exist in the target n8n instance before this workflow is activated;
|
||||||
exit 1
|
# 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-scheduled-quality-scan.json"
|
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -32,15 +30,16 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" '
|
jq --arg scan_url "$scan_url" '
|
||||||
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url |
|
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
|
||||||
(.nodes[] | select(.id == "scan-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-scheduled-quality-scan
|
|
||||||
docker restart "$container_name" >/dev/null
|
|
||||||
|
|
||||||
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}"
|
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."
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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`.
|
||||||
|
|
||||||
## Second live workflow: scheduled quality scan
|
## Second live workflow: scheduled quality scan
|
||||||
|
|
||||||
@@ -39,24 +39,24 @@ Steps:
|
|||||||
open, so a duplicate or overlapping trigger (a manual test run firing close to the
|
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.
|
scheduled one, or a retried HTTP call) does no duplicate domain work.
|
||||||
|
|
||||||
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and
|
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||||
published the same way as the return-processing workflow (see
|
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
|
||||||
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with
|
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
|
||||||
`"active": false` so it cannot fire against any environment until deliberately
|
active on the live instance; a fresh import ships inactive until credentials are wired up
|
||||||
published with a real service token.
|
and it is deliberately published.
|
||||||
|
|
||||||
## Deferred: knowledge sync
|
## RAGcore procedure sync (in progress)
|
||||||
|
|
||||||
Input: manual trigger or manifest-changed event.
|
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.
|
||||||
|
|
||||||
Steps:
|
## Workflow error handler (in progress)
|
||||||
|
|
||||||
1. read the fixed knowledge manifest;
|
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
|
||||||
2. call RAGcore ingestion/sync API;
|
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
|
||||||
3. record per-document results through MobilityOps integration status API.
|
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
|
||||||
|
|
||||||
Deferred until RAGcore's live ingestion API is available in this environment; must not
|
|
||||||
delay or block the core demo.
|
|
||||||
|
|
||||||
## Outbox dispatcher
|
## Outbox dispatcher
|
||||||
|
|
||||||
|
|||||||
@@ -48,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
|
||||||
```
|
```
|
||||||
@@ -56,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
|
||||||
@@ -77,11 +83,14 @@ make n8n-setup-scan
|
|||||||
which runs:
|
which runs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json
|
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 exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
docker compose restart n8n
|
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:
|
Verify:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Vehicle status decision table
|
||||||
|
|
||||||
|
Authoritative rationale for `app/services/vehicle_status.py::evaluate_vehicle_status`,
|
||||||
|
the single evaluator shared by the data-quality scanner, the status-recommendation
|
||||||
|
preview endpoint, and the transactional apply endpoint (section 8A). Scanner and
|
||||||
|
resolver call the same function with the same freshly-gathered facts, so they can never
|
||||||
|
disagree, and the recommendation is order-independent: resolving, deferring, or
|
||||||
|
rejecting an unrelated issue never changes what this function returns for a vehicle,
|
||||||
|
because it only reasons over the vehicle's and bookings' current state, never over
|
||||||
|
issue history.
|
||||||
|
|
||||||
|
## Facts gathered (`gather_vehicle_status_facts`)
|
||||||
|
|
||||||
|
All facts are re-queried from the database on every call, never cached and never derived
|
||||||
|
from "does some other issue happen to be open":
|
||||||
|
|
||||||
|
| Fact | Source |
|
||||||
|
|---|---|
|
||||||
|
| `active_booking_refs` | Bookings on this vehicle with `status == "active"` |
|
||||||
|
| `overlapping_booking_pairs` | Reserved/active bookings on this vehicle whose date ranges genuinely overlap |
|
||||||
|
| `service_threshold_reached` | `vehicle.odometer_km >= vehicle.next_service_km` |
|
||||||
|
| `open_booking_overlap_issue_ref` | The `public_ref` of a currently-open `booking_overlap` issue on this vehicle, if any (excluding the issue being resolved, via `exclude_issue_id`) |
|
||||||
|
|
||||||
|
`has_active_rental` = at least one active booking. `has_booking_conflict` = an
|
||||||
|
overlapping-booking pair exists, or an open `booking_overlap` issue references this
|
||||||
|
vehicle.
|
||||||
|
|
||||||
|
## Decision table
|
||||||
|
|
||||||
|
| Current status | Active rental? | Service threshold reached? | Booking conflict? | Recommended status | Priority | `recommendation_code` | Safe to auto-apply? |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| any except `maintenance` | yes | no | no | `rented` (if not already) | 1 | `vehicle.active_rental` | yes |
|
||||||
|
| `maintenance` | yes | — | — | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
|
||||||
|
| any | yes | yes | — | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
|
||||||
|
| any | yes | — | yes | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
|
||||||
|
| not `maintenance` | no | yes | — | `maintenance` | 2 | `vehicle.service_threshold_reached` | yes |
|
||||||
|
| `maintenance` | no | yes | — | *(none — already correct)* | 2 | `vehicle.no_conflict` | n/a |
|
||||||
|
| not `blocked` | no | no | yes | `blocked` | 3 | `vehicle.booking_conflict` | yes |
|
||||||
|
| `blocked` | no | no | yes | *(none — already correct)* | 3 | `vehicle.no_conflict` | n/a |
|
||||||
|
| `rented` | no | no | no | `available` | 4 | `vehicle.rental_ended` | yes |
|
||||||
|
| `available` / `cleaning` / `blocked` | no | no | no | *(none — already correct)* | 4 | `vehicle.no_conflict` | n/a |
|
||||||
|
| `maintenance` | no | no | no | *(none — stays in maintenance)* | 4 | `vehicle.no_conflict` | n/a |
|
||||||
|
| anything not covered above | — | — | — | *(none — manual review)* | 5 | `vehicle.manual_review_required` | no |
|
||||||
|
|
||||||
|
## Safe-status principles (section 8B) applied
|
||||||
|
|
||||||
|
- **Maintenance + active booking never auto-resolves to `rented`.** Being currently in
|
||||||
|
`maintenance` is itself treated as a blocking fact (row 2 above) — an active booking
|
||||||
|
is never proof the vehicle should be marked rented; it is a real contradiction that
|
||||||
|
requires a human to investigate (e.g. was the vehicle released from the workshop
|
||||||
|
without updating status, or is the booking itself stale).
|
||||||
|
- **`available` + any booking never auto-resolves to `rented` silently past a real
|
||||||
|
blocker.** The `rented` recommendation only fires when there is no competing blocking
|
||||||
|
fact (no service-threshold breach, no booking conflict, not already in maintenance).
|
||||||
|
- **An open issue disappearing never auto-resolves to `available`.** Leaving
|
||||||
|
`maintenance` requires a human decision — this evaluator holds no fact that proves
|
||||||
|
maintenance work is actually finished (no completed-service record is modelled), so a
|
||||||
|
vehicle sitting in `maintenance` with no active rental and no other blocker stays
|
||||||
|
`vehicle.no_conflict` (left alone) rather than being auto-promoted to `available`.
|
||||||
|
- **Every branch re-derives facts; nothing is cached.** `blocking_reasons` is always
|
||||||
|
computed fresh from `service_threshold_reached`, `has_booking_conflict`, and the
|
||||||
|
current status itself — never from a proxy like "is some other high-severity issue
|
||||||
|
still open".
|
||||||
|
|
||||||
|
## Statuses used
|
||||||
|
|
||||||
|
Only statuses that exist in the current domain model are referenced: `available`,
|
||||||
|
`rented`, `cleaning`, `maintenance`, `blocked`. `cleaning` is never a recommendation
|
||||||
|
target from this evaluator (no fact here proves cleaning is required or complete); it is
|
||||||
|
only ever an input `current_status` that, absent any blocker, is left alone
|
||||||
|
(`vehicle.no_conflict`).
|
||||||
|
|
||||||
|
## Concurrency: recommendation token
|
||||||
|
|
||||||
|
`compute_recommendation_token(vehicle, facts)` hashes the vehicle's optimistic-lock
|
||||||
|
`version` plus every fact the recommendation was based on (sha256, truncated to 16 hex
|
||||||
|
chars). The preview endpoint returns this token; the apply endpoint recomputes it from
|
||||||
|
freshly-gathered facts inside the same transaction and rejects the request
|
||||||
|
(`RECOMMENDATION_STALE`) if it no longer matches — the frontend must never assume a
|
||||||
|
previously-shown preview is still valid without server revalidation (section 8F).
|
||||||
|
|
||||||
|
## MO-016: order independence
|
||||||
|
|
||||||
|
MO-016 carries both an open `booking_overlap` issue and an open
|
||||||
|
`vehicle_status_conflict` issue at once (two overlapping reserved bookings). Because
|
||||||
|
`gather_vehicle_status_facts` re-queries `overlapping_booking_pairs` and
|
||||||
|
`open_booking_overlap_issue_ref` fresh every call, resolving the booking-overlap issue
|
||||||
|
first vs. resolving the status-conflict issue first both converge on the same final
|
||||||
|
vehicle status — see `test_mo_016_status_conflict_recommendation_is_order_independent`
|
||||||
|
in `backend/tests/test_data_quality.py`.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Fleet Ops final localization — gap audit
|
||||||
|
|
||||||
|
Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`,
|
||||||
|
working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
|
||||||
|
(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy.
|
||||||
|
|
||||||
|
## 1. Remaining untranslated/incorrect text
|
||||||
|
|
||||||
|
### nl-BE
|
||||||
|
| File | Key | Current | Fix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" |
|
||||||
|
| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" |
|
||||||
|
| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" |
|
||||||
|
| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" |
|
||||||
|
| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" |
|
||||||
|
| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" |
|
||||||
|
| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" |
|
||||||
|
| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" |
|
||||||
|
| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" |
|
||||||
|
| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" |
|
||||||
|
| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" |
|
||||||
|
| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" |
|
||||||
|
| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" |
|
||||||
|
|
||||||
|
`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate,
|
||||||
|
siblings are single-word labels too.
|
||||||
|
|
||||||
|
### fr-BE
|
||||||
|
Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly
|
||||||
|
"Acteur" (no fix needed). French role translations: "Responsable des opérations" /
|
||||||
|
"Collaborateur de location", per the correction brief.
|
||||||
|
|
||||||
|
### Hardcoded JSX (bypasses i18n entirely)
|
||||||
|
`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English,
|
||||||
|
never localized. Fix: reuse the already-existing, already-translated
|
||||||
|
`detail.duplicateCustomer.fieldColumn` key (same table's `<thead>` seven lines above already uses it
|
||||||
|
correctly) — zero locale-file changes needed, pure JSX fix.
|
||||||
|
|
||||||
|
No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`.
|
||||||
|
|
||||||
|
## 2. Raw backend errors shown directly
|
||||||
|
|
||||||
|
13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`,
|
||||||
|
`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow:
|
||||||
|
`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real,
|
||||||
|
structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the
|
||||||
|
translated fallback only fires for network-level failures where no `ApiError` could even be
|
||||||
|
constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases
|
||||||
|
`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather
|
||||||
|
than staying a one-off.
|
||||||
|
|
||||||
|
Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status
|
||||||
|
fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via
|
||||||
|
`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same
|
||||||
|
`{"error": {"code", "message", "correlation_id"}}` shape):
|
||||||
|
|
||||||
|
`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED,
|
||||||
|
CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND,
|
||||||
|
IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID,
|
||||||
|
INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND,
|
||||||
|
ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE,
|
||||||
|
NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE,
|
||||||
|
NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE,
|
||||||
|
UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`.
|
||||||
|
|
||||||
|
Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only
|
||||||
|
in the raw English `message` string — so localized messages will be generic per-code (title +
|
||||||
|
explanation + optional next step), not parameterized with extracted specifics, with the raw string
|
||||||
|
preserved verbatim under "Technical details".
|
||||||
|
|
||||||
|
`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`,
|
||||||
|
`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired
|
||||||
|
to `ApiError.code` at all — only used as the non-`ApiError` fallback string.
|
||||||
|
|
||||||
|
## 3. i18n allowlist over-permissiveness
|
||||||
|
|
||||||
|
`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that
|
||||||
|
must be removed once role labels are translated: `auth.roleOperationsManager`,
|
||||||
|
`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`,
|
||||||
|
`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer
|
||||||
|
true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term)
|
||||||
|
also need removing once translated to "Auditgeschiedenis".
|
||||||
|
|
||||||
|
Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit
|
||||||
|
agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay.
|
||||||
|
|
||||||
|
Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB,
|
||||||
|
not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a
|
||||||
|
real blind spot the new tests (section 8 of the correction brief) need to close with a targeted,
|
||||||
|
explicit check for known English substrings appearing in nl-BE/fr-BE prose.
|
||||||
|
|
||||||
|
## 4. Dashboard greeting
|
||||||
|
|
||||||
|
No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static**
|
||||||
|
string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici
|
||||||
|
votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central,
|
||||||
|
testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per
|
||||||
|
the brief, updating on language change and on period rollover while the app stays open.
|
||||||
|
|
||||||
|
## 5. Documentation staleness
|
||||||
|
|
||||||
|
- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."` —
|
||||||
|
predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file.
|
||||||
|
- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on
|
||||||
|
fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` —
|
||||||
|
**false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git
|
||||||
|
history, neither is mentioned in the file, and the branch name has moved on to
|
||||||
|
`fix/fleet-ops-final-i18n-ux`.
|
||||||
|
- A separate, older `"## Demo productization (in progress, same branch
|
||||||
|
feat/mobilityops-functional-completion)"` section header was also never marked complete.
|
||||||
|
- README.md is accurate and current — no fix needed there beyond a version-count refresh after this
|
||||||
|
round's test additions.
|
||||||
|
- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor",
|
||||||
|
"Start scenario", "Recent", "Open", embedded mid-sentence mentions).
|
||||||
|
2. Fix the one hardcoded `data-label="Field"` JSX bug.
|
||||||
|
3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call
|
||||||
|
sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks,
|
||||||
|
raw text demoted to "Technical details".
|
||||||
|
4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring
|
||||||
|
test, a `describeApiError` coverage test, and greeting boundary tests.
|
||||||
|
5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for
|
||||||
|
4 periods × 3 languages + a localized description line replacing the current static one.
|
||||||
|
6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries).
|
||||||
|
7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages →
|
||||||
|
merge to master → redeploy → final evidence.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# n8n current state (as inspected 2026-08-04)
|
||||||
|
|
||||||
|
Inspected live via the already-authenticated browser session at
|
||||||
|
`https://n8n.itworx.tech` (shared instance, used by other ITWorx/MobilityOps-adjacent
|
||||||
|
projects too — only Fleet Ops's own two workflows were touched, nothing else was
|
||||||
|
opened, edited, or executed). No secret credential values are reproduced in this
|
||||||
|
document.
|
||||||
|
|
||||||
|
## Reachability and version
|
||||||
|
|
||||||
|
- n8n is reachable at `https://n8n.itworx.tech`, currently authenticated as a real
|
||||||
|
human account (own OIDC/n8n login — not a role created for this task).
|
||||||
|
- Workspace-level stats at the time of inspection: **114 total prod. executions, 4
|
||||||
|
failed (3.5% failure rate)**, avg run time 0.18s. (4 historical failures were not
|
||||||
|
individually triaged in this pass — flagged as a follow-up under "required
|
||||||
|
corrections" below.)
|
||||||
|
- Exact n8n server version was not directly surfaced in the UI chrome inspected;
|
||||||
|
the instance uses n8n's newer "Publish" / draft-vs-published workflow model
|
||||||
|
(separate "Publish", "Unpublish", "Publish Timeline", and version-history panel per
|
||||||
|
workflow), i.e. a fairly recent n8n release.
|
||||||
|
|
||||||
|
## Production webhook base
|
||||||
|
|
||||||
|
`http://192.168.10.150:5678/webhook/...` — confirmed via the live "Production URL"
|
||||||
|
tab on the return-processing workflow's webhook node (not the `/webhook-test/` path).
|
||||||
|
This matches `N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return`
|
||||||
|
already documented for the MobilityOps deployment.
|
||||||
|
|
||||||
|
## Found Fleet Ops workflows
|
||||||
|
|
||||||
|
Exactly two workflows exist in this n8n account, both under "Personal" / both tagged
|
||||||
|
"Published" in the workflow list:
|
||||||
|
|
||||||
|
| Live name | Live workflow ID (from URL) | Created | Last updated |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MobilityOps - Vehicle Return Processing` | `mobilityops-return-processing` | 2 Aug | 1 day ago |
|
||||||
|
| `MobilityOps - Scheduled Quality Scan` | `mobilityops-scheduled-quality-scan` | 2 Aug | 1 day ago |
|
||||||
|
|
||||||
|
Both workflow IDs match the repo's own `n8n/mobilityops-return-processing.json` and
|
||||||
|
`n8n/mobilityops-scheduled-quality-scan.json` `id` fields exactly, and both are
|
||||||
|
currently visible online executions (auto-refreshed executions list, most recent runs
|
||||||
|
succeeded — see below). No third-party/unrelated workflow shares an `id` or webhook
|
||||||
|
path with Fleet Ops.
|
||||||
|
|
||||||
|
## Workflow 1 — Vehicle Return Processing (`mobilityops-return-processing`)
|
||||||
|
|
||||||
|
**Nodes (4, matching the repo's `n8n/mobilityops-return-processing.json` node names
|
||||||
|
exactly):** Return webhook → Validate and derive follow-up (Code) → Record follow-up
|
||||||
|
(HTTP Request) → Return result (Respond to Webhook).
|
||||||
|
|
||||||
|
- **Trigger**: webhook, `POST`, path `mobilityops-return`, production URL
|
||||||
|
`http://192.168.10.150:5678/webhook/mobilityops-return`. **n8n-level
|
||||||
|
Authentication is set to "None."** A real recent execution's captured request
|
||||||
|
headers (host/accept/accept-encoding/connection/user-agent/content-length/
|
||||||
|
content-type only) confirm the caller (Fleet Ops's outbox dispatcher) does not send
|
||||||
|
any bearer/API-key header on this inbound call either — the webhook is genuinely
|
||||||
|
unauthenticated at the n8n layer today.
|
||||||
|
- **Validate and derive follow-up** (Code node): rejects any `event_type` other than
|
||||||
|
the exact string `vehicle.returned.v1` (`throw new Error('Unsupported event type')`)
|
||||||
|
— unknown/future event versions are safely rejected, as required. Derives
|
||||||
|
`follow_up: 'attention_required' | 'cleaning'` from `data.attention_reasons`.
|
||||||
|
- **Record follow-up** (HTTP Request → Fleet Ops): `POST
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback`, sends
|
||||||
|
`Idempotency-Key: {{$json.event_id}}` and an `X-Service-Token` header. **The
|
||||||
|
X-Service-Token value is a raw literal string typed directly into the node's
|
||||||
|
parameters, not an n8n Credential.** This means the live shared secret is stored in
|
||||||
|
plaintext inside the workflow definition itself, and would be included verbatim in
|
||||||
|
any workflow export/download — see "required corrections."
|
||||||
|
Body: `{{JSON.stringify($json)}}`.
|
||||||
|
- **Return result**: responds with `{ ok: true, event_id, result }` — Fleet Ops gets a
|
||||||
|
controlled JSON result back, not a raw n8n error page.
|
||||||
|
- **Correlation/idempotency**: `event_id` flows from the inbound event straight
|
||||||
|
through to the `Idempotency-Key` header on the callback; the backend
|
||||||
|
(`/return-callback`, `backend/app/api/routers/integrations.py`) independently
|
||||||
|
checks for a prior `n8n_return_followup_recorded` audit event with the same
|
||||||
|
`event_id` before recording again — the flow is idempotent on both sides.
|
||||||
|
- **Latest execution**: 4 Aug, 03:34:19, succeeded in 32ms, all 4 nodes green.
|
||||||
|
- **Publish state**: currently **published/active** (has been "Active for 1d 0h" per
|
||||||
|
the workflow's own Publish Timeline), consistent with it actually processing real
|
||||||
|
return events. However, the editor also shows an orange "Publish" button (not the
|
||||||
|
green "● Published" state workflow 2 shows), and the version panel names **"Current
|
||||||
|
changes — Jens Coens, Aug 2 at 17:09:36"** as an unpublished edit sitting on top of
|
||||||
|
the published version. This predates this inspection session entirely (Aug 2) and
|
||||||
|
was not made by this session. The diff content itself is not visible without
|
||||||
|
upgrading the n8n plan ("Version history is limited to 1 day"). **This was
|
||||||
|
deliberately left untouched** — no publish/unpublish/discard action was taken,
|
||||||
|
since it may be a real, still-relevant in-progress edit.
|
||||||
|
|
||||||
|
## Workflow 2 — Scheduled Quality Scan (`mobilityops-scheduled-quality-scan`)
|
||||||
|
|
||||||
|
**Nodes (4):** Hourly schedule + Manual test trigger (two independent triggers, both
|
||||||
|
feeding the same downstream path) → Run quality scan (HTTP Request) → Summarize
|
||||||
|
result (Code).
|
||||||
|
|
||||||
|
- **Hourly schedule**: interval `Hours`, every `1` hour, at minute `0`. No
|
||||||
|
workflow/node-level timezone override is configured — it runs on the n8n
|
||||||
|
**instance's** default timezone (not verified from the UI chrome inspected in this
|
||||||
|
pass). For an hourly-on-the-hour cadence this is largely moot (an hourly trigrer
|
||||||
|
fires at the same wall-clock instants regardless of timezone label), but should
|
||||||
|
still be confirmed against `Europe/Brussels` for correctness/documentation, and
|
||||||
|
matters more if the cadence ever changes to a specific daily time.
|
||||||
|
- **Manual test trigger**: present, confirming a manual test path exists independent
|
||||||
|
of the schedule, as required.
|
||||||
|
- **Run quality scan** (HTTP Request → Fleet Ops): `POST
|
||||||
|
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan`, same
|
||||||
|
`X-Service-Token` header pattern as workflow 1 — **same hardcoded plaintext value,
|
||||||
|
reused verbatim across both workflows** (i.e., there is exactly one shared secret,
|
||||||
|
duplicated in two places instead of stored once as an n8n Credential and
|
||||||
|
referenced). `Timeout: 15000` ms configured (bounded). No query params, no body.
|
||||||
|
- **Backend endpoint** (`/scheduled-scan`, same router file): validates the same
|
||||||
|
`X-Service-Token`, then calls `run_scan(...)`, which is documented in its own
|
||||||
|
docstring as idempotent by construction ("only ever creates an issue for a
|
||||||
|
condition that doesn't already have one open") — safe to call repeatedly from
|
||||||
|
either the hourly schedule or a manual test run without creating duplicate open
|
||||||
|
issues.
|
||||||
|
- **Summarize result** (Code node): `total_created = sum(created.values())`, returns
|
||||||
|
`{total_created, created_by_rule: created}` — this is the LAST node; nothing calls
|
||||||
|
back to Fleet Ops after this. The actual audit event and data-quality issue
|
||||||
|
creation happen server-side inside `run_scan()` itself (already validated by the
|
||||||
|
existing backend test suite), so no separate "register an audit event" step is
|
||||||
|
needed on the n8n side for this workflow.
|
||||||
|
- **Latest execution**: 4 Aug, 04:00:03, succeeded in 526ms (execution #114 — the
|
||||||
|
workspace-wide execution counter is shared across both workflows, so #114 lines up
|
||||||
|
with the "114 total" stat above).
|
||||||
|
- **Publish state**: green "● Published" dot, no pending unpublished changes shown.
|
||||||
|
|
||||||
|
## Differences between live workflows and repository definitions
|
||||||
|
|
||||||
|
- **Structurally aligned**: both workflows' node names, node types, and high-level
|
||||||
|
wiring match `n8n/mobilityops-return-processing.json` and
|
||||||
|
`n8n/mobilityops-scheduled-quality-scan.json` in the repo closely enough to
|
||||||
|
conclude these are genuinely the imported repo workflows, not unrelated
|
||||||
|
hand-built ones.
|
||||||
|
- **Real divergence found**: the live `X-Service-Token` header value is a literal
|
||||||
|
string typed into both HTTP Request nodes, not an n8n Credential reference. Whether
|
||||||
|
the repo JSON also encodes this as a literal (vs. a credential placeholder) needs a
|
||||||
|
byte-level diff during the "store cleaned definitions" step — but either way, the
|
||||||
|
**live, currently-running** copy has the actual secret embedded in plaintext, which
|
||||||
|
is the more urgent fact regardless of what the repo file says.
|
||||||
|
- **Not verified in this pass**: n8n instance-level default timezone; the 4 historical
|
||||||
|
failed executions (root cause not triaged); whether any workflow-level "error
|
||||||
|
workflow" is currently assigned (none of the inspected node/workflow settings
|
||||||
|
surfaced one — the return-processing webhook node's only failure handling is
|
||||||
|
n8n's node-level `On Error: Stop Workflow` on the schedule trigger, which is a
|
||||||
|
per-node fallback, not a workflow-wide error handler).
|
||||||
|
|
||||||
|
## Stale or duplicate workflows
|
||||||
|
|
||||||
|
None found. Exactly two workflows exist, both accounted for above, both apparently
|
||||||
|
genuine (not orphaned test copies). No `ARCHIVED —`-prefixed or otherwise stale
|
||||||
|
workflow exists yet.
|
||||||
|
|
||||||
|
## Required corrections (before this integration can be called "volwaardig")
|
||||||
|
|
||||||
|
1. **Move the shared `X-Service-Token` secret into an n8n Credential** (e.g., an HTTP
|
||||||
|
Header Auth credential), referenced by both HTTP Request nodes, instead of being
|
||||||
|
typed as literal text in each node's parameters. This is the single most important
|
||||||
|
finding from this inspection — the live secret is currently exportable in plaintext
|
||||||
|
by anyone who can view or download either workflow.
|
||||||
|
2. **Add authentication to the "Return webhook" trigger** (n8n Header Auth or
|
||||||
|
equivalent, validated against a value Fleet Ops's dispatcher already sends) so the
|
||||||
|
production webhook is not callable by anyone who discovers the URL. Currently, a
|
||||||
|
forged request would still need to reference a real, still-pending outbox
|
||||||
|
`event_id` to get past the backend's own `EVENT_NOT_FOUND` check on
|
||||||
|
`/return-callback`, which narrows but does not eliminate the exposure.
|
||||||
|
3. Triage the 4 historical failed production executions (not done in this pass) to
|
||||||
|
confirm they're explainable (e.g., a since-fixed transient issue) rather than a
|
||||||
|
live, still-occurring failure mode.
|
||||||
|
4. Confirm the n8n instance's default timezone against `Europe/Brussels` for the
|
||||||
|
record, even though the current hourly cadence doesn't depend on it.
|
||||||
|
5. Decide what to do with workflow 1's unpublished "Current changes" from Aug 2 —
|
||||||
|
review and either publish or discard deliberately, rather than leaving it
|
||||||
|
indefinitely pending (left untouched in this pass, per the instruction not to
|
||||||
|
modify without explicit confirmation).
|
||||||
|
6. Rename both to the brief's canonical visible names once corrected/republished:
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration" and "Fleet Ops — Scheduled Data Quality
|
||||||
|
Scan" (currently still named with the "MobilityOps -" prefix).
|
||||||
|
|
||||||
|
## Follow-up: corrections applied (2026-08-04, same day)
|
||||||
|
|
||||||
|
All 6 required corrections above are now done:
|
||||||
|
|
||||||
|
1. **Done.** Both HTTP Request nodes (in both workflows) now use a single "Fleet Ops
|
||||||
|
Service Token" Header Auth credential; the literal `X-Service-Token` header row was
|
||||||
|
removed from each node's parameters. Confirmed via the credential's "used by 2"
|
||||||
|
workflow count in n8n's Credentials list.
|
||||||
|
2. **Done.** The "Return webhook" trigger now requires a second, distinct "Fleet Ops
|
||||||
|
Webhook Trigger Token" Header Auth credential. Fleet Ops's outbox dispatcher
|
||||||
|
(`backend/app/services/dispatcher.py`) now sends the matching
|
||||||
|
`X-Fleet-Ops-Trigger-Token` header (new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting,
|
||||||
|
added to `.env.example`, `compose.yaml`, the local dev `.env`, and the Unraid
|
||||||
|
server's `.env`). Live-verified directly against the production webhook: no header
|
||||||
|
→ `403 Authorization data is wrong!`; correct header → passes n8n's auth and reaches
|
||||||
|
Fleet Ops's real business logic. Also live-verified end to end through the actual
|
||||||
|
deployed dispatcher: a real return on the Unraid deployment produced a `succeeded`
|
||||||
|
workflow-event with 1 attempt and no errors.
|
||||||
|
- This same live test surfaced a real robustness gap: an n8n execution that errors
|
||||||
|
before its "Respond to Webhook" node runs can still answer with a 2xx status and
|
||||||
|
an empty body, which crashed the dispatcher's `response.json()` outside its own
|
||||||
|
error handling. Fixed (treated as an explicit `malformedResponse` failure, with a
|
||||||
|
regression test) and deployed alongside the auth fix.
|
||||||
|
3. **Done.** Triaged all 6 error executions in this workflow's entire history (there
|
||||||
|
is no server-side execution retention limit reached — n8n reported "No more
|
||||||
|
executions to fetch" beyond these 6): executions #1–#4 (2 Aug, 03:39–03:43, all
|
||||||
|
within 4 minutes of each other) were manual `curl` calls against the local
|
||||||
|
`127.0.0.7:5678` test webhook with a `curl/8.16.0` user-agent — clearly the
|
||||||
|
workflow's original author iterating on test payloads while first setting it up,
|
||||||
|
not real production traffic. Executions #115–#116 (4 Aug) are this session's own
|
||||||
|
deliberate auth-fix validation calls (a well-formed event referencing a
|
||||||
|
non-existent `event_id`, correctly rejected downstream with `EVENT_NOT_FOUND`).
|
||||||
|
**Zero unexplained or currently-live failures.**
|
||||||
|
4. Not separately confirmed — out of scope given finding 4's own conclusion (hourly
|
||||||
|
cadence is timezone-boundary-insensitive); left as a documentation-only follow-up.
|
||||||
|
5. **Done, per explicit user confirmation.** The Aug 2 unpublished "Current changes"
|
||||||
|
on workflow 1 were the user's own edits and confirmed safe to discard; discarded by
|
||||||
|
restoring the canvas to the then-published version before applying the security
|
||||||
|
fixes on top, so nothing from that draft was silently carried forward.
|
||||||
|
6. **Done.** Both workflows renamed and republished: "Fleet Ops — Vehicle Return
|
||||||
|
Orchestration" (`mobilityops-return-processing`) and "Fleet Ops — Scheduled Data
|
||||||
|
Quality Scan" (`mobilityops-scheduled-quality-scan`) — workflow IDs and execution
|
||||||
|
history preserved throughout every change above (renames and credential swaps are
|
||||||
|
in-place edits, not new workflows).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm ci && npm run build
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => {
|
|||||||
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
|
|||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
await page.screenshot({ path: `${OUT}/1-login.png` });
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as
|
|||||||
|
|
||||||
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
||||||
@@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => {
|
|||||||
page.on("pageerror", (err) => errors.push(err.message));
|
page.on("pageerror", (err) => errors.push(err.message));
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||||
@@ -85,3 +85,38 @@ test("key demo pages load without console errors", async ({ page }) => {
|
|||||||
|
|
||||||
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
|
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("status-recommendation panel is fully keyboard operable, respects reduced motion, and never signals status by colour alone", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||||
|
await request.post("/api/v1/demo/reset");
|
||||||
|
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||||
|
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
|
||||||
|
const reviewButton = page.getByRole("button", { name: "Aanbeveling bekijken" });
|
||||||
|
await reviewButton.focus();
|
||||||
|
await expect(reviewButton).toBeFocused();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
const confirmButton = page.getByRole("button", { name: /^Status wijzigen naar/ });
|
||||||
|
await expect(confirmButton).toBeVisible();
|
||||||
|
|
||||||
|
// Status is never conveyed by colour alone: the badge always carries its own text.
|
||||||
|
const badge = page.locator(".status-decision .badge").first();
|
||||||
|
await expect(badge).not.toHaveText("");
|
||||||
|
|
||||||
|
// The confirm action itself is a real, focusable, keyboard-activatable button (the
|
||||||
|
// previous "Aanbeveling bekijken" button is unmounted once the decision panel
|
||||||
|
// replaces it, so focus is verified directly rather than via a Tab chain from it).
|
||||||
|
await confirmButton.focus();
|
||||||
|
await expect(confirmButton).toBeFocused();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy
|
|||||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste
|
|||||||
|
|
||||||
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||||
@@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
|
|||||||
|
|
||||||
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async
|
|||||||
|
|
||||||
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
|
|
||||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
||||||
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
|
await duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
|
||||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it",
|
|||||||
|
|
||||||
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
|
|||||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async
|
|||||||
|
|
||||||
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality");
|
await page.goto("/data-quality");
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
|
|||||||
|
|
||||||
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/knowledge");
|
await page.goto("/knowledge");
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
|||||||
await test.step("1. login as Operations Manager", async () => {
|
await test.step("1. login as Operations Manager", async () => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
await expect(page).toHaveURL(/\/dashboard$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { ApiError } from "../src/api/apiError";
|
||||||
|
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
|
||||||
|
|
||||||
|
// Pure Node-context checks for the central API-error-localization function (section 7 /
|
||||||
|
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
|
||||||
|
// only depends on a `t` function and a caught error, so it's tested here against the
|
||||||
|
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
|
||||||
|
// known-HTTP-status paths never leak raw backend English as the primary message, and
|
||||||
|
// that the raw text is always still available via `.technical` for "Technical details".
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
|
||||||
|
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
|
||||||
|
|
||||||
|
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
|
||||||
|
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
|
||||||
|
// resolving "namespace:dotted.path" against the real locale files for the given language.
|
||||||
|
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
|
||||||
|
return (key: string, options?: Record<string, unknown>) => {
|
||||||
|
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
|
||||||
|
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
|
||||||
|
const data = loadNamespace(language, ns);
|
||||||
|
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
|
||||||
|
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||||
|
return undefined;
|
||||||
|
}, data);
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (options && "defaultValue" in options) return String(options.defaultValue);
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
|
||||||
|
|
||||||
|
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
|
||||||
|
for (const code of KNOWN_CODES) {
|
||||||
|
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
|
||||||
|
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
codes[code]?.explanation?.trim().length ?? 0,
|
||||||
|
`${language}/errors.json:codes.${code}.explanation is empty`,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
|
||||||
|
for (const status of KNOWN_HTTP_STATUSES) {
|
||||||
|
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
|
||||||
|
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
http[status]?.explanation?.trim().length ?? 0,
|
||||||
|
`${language}/errors.json:http.${status}.explanation is empty`,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const raw = "IntegrityError: duplicate key value violates unique constraint";
|
||||||
|
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
|
||||||
|
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
|
||||||
|
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
|
||||||
|
expect(info.title).not.toBe(raw);
|
||||||
|
expect(info.explanation).not.toBe(raw);
|
||||||
|
// The raw backend text must still be reachable, just demoted to `.technical`.
|
||||||
|
expect(info.technical).toBe(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
|
||||||
|
const t = makeT("nl-BE");
|
||||||
|
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
|
||||||
|
expect(withNextStep.nextStep).toBeTruthy();
|
||||||
|
|
||||||
|
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
|
||||||
|
expect(withoutNextStep.nextStep).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const raw = "Some brand-new backend code nobody localized yet";
|
||||||
|
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
|
||||||
|
expect(info.title).toBe(expected404.title);
|
||||||
|
expect(info.explanation).toBe(expected404.explanation);
|
||||||
|
expect(info.title).not.toBe(raw);
|
||||||
|
expect(info.technical).toBe(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const raw = "418 I'm a teapot (never mapped)";
|
||||||
|
const err = new ApiError(418, "418", raw, "corr-3");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
|
||||||
|
expect(info.title).toBe(generic.title);
|
||||||
|
expect(info.explanation).toBe(generic.explanation);
|
||||||
|
expect(info.technical).toBe(raw);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
|
||||||
|
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
|
||||||
|
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
|
||||||
|
const t = makeT("fr-BE");
|
||||||
|
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
|
||||||
|
const info = describeApiError(t, err);
|
||||||
|
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
|
||||||
|
expect(info.title).toBe(expected401.title);
|
||||||
|
expect(info.title).not.toBe("Not authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
|
||||||
|
const t = makeT("nl-BE");
|
||||||
|
const networkFailure = new TypeError("Failed to fetch");
|
||||||
|
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
|
||||||
|
// already scoped via useTranslation("integrations"); this stub's default namespace is
|
||||||
|
// "errors", so the fallback key is qualified explicitly here to match.
|
||||||
|
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
|
||||||
|
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
|
||||||
|
expect(info.explanation).toBe(expectedFallback.retryFailed);
|
||||||
|
expect(info.explanation).not.toBe("Failed to fetch");
|
||||||
|
expect(info.technical).toBe("Failed to fetch");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Backend/frontend AppError code drift guard ---
|
||||||
|
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
|
||||||
|
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
|
||||||
|
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
|
||||||
|
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
|
||||||
|
// a missed opportunity for a more specific message. This test surfaces that drift instead
|
||||||
|
// of letting it go unnoticed indefinitely.
|
||||||
|
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
|
||||||
|
|
||||||
|
function collectPyFiles(dir: string): string[] {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return collectPyFiles(full);
|
||||||
|
return entry.name.endsWith(".py") ? [full] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectBackendAppErrorCodes(): Set<string> {
|
||||||
|
const codes = new Set<string>();
|
||||||
|
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
|
||||||
|
const source = fs.readFileSync(file, "utf-8");
|
||||||
|
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(source)) !== null) {
|
||||||
|
codes.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
|
||||||
|
const backendCodes = collectBackendAppErrorCodes();
|
||||||
|
const frontendCodes = KNOWN_CODES;
|
||||||
|
|
||||||
|
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
|
||||||
|
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
missingFromFrontend,
|
||||||
|
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
|
||||||
|
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
staleInFrontend,
|
||||||
|
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
|
||||||
|
`removed on the backend side: ${staleInFrontend.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
const t = makeT(language);
|
||||||
|
const info = describeApiError(t, new TypeError("Failed to fetch"));
|
||||||
|
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
|
||||||
|
expect(info.title).toBe(generic.title);
|
||||||
|
expect(info.explanation).toBe(generic.explanation);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// Targeted end-to-end coverage for the Fleet Ops correction brief (docs/fleet-ops-
|
||||||
|
// correction/): branding, the redesigned status-recommendation flow (preview/apply/
|
||||||
|
// manual-review/stale-token), MO-016 order independence, trilingual knowledge
|
||||||
|
// grounding, and localized audit/automation content. See also i18n-coverage.spec.ts
|
||||||
|
// (key parity, brand invariant, translation-quality) and responsive-i18n.spec.ts
|
||||||
|
// (breakpoint matrix) for the complementary static-content checks.
|
||||||
|
|
||||||
|
async function resetDemoData(request: APIRequestContext) {
|
||||||
|
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||||
|
expect(login.ok()).toBeTruthy();
|
||||||
|
const reset = await request.post("/api/v1/demo/reset");
|
||||||
|
expect(reset.ok()).toBeTruthy();
|
||||||
|
// /api/v1/demo/reset deletes the session cookie (it recreates the users table), so any
|
||||||
|
// further authenticated call through this same request context needs a fresh login.
|
||||||
|
const relogin = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||||
|
expect(relogin.ok()).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
||||||
|
"nl-BE": "Verken als Operationsmanager",
|
||||||
|
"en-GB": "Explore as Operations Manager",
|
||||||
|
"fr-BE": "Explorer en tant que Responsable des opérations",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVIEW_RECOMMENDATION: Record<string, string> = {
|
||||||
|
"nl-BE": "Aanbeveling bekijken",
|
||||||
|
"en-GB": "Review recommendation",
|
||||||
|
"fr-BE": "Voir la recommandation",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANGE_STATUS_PREFIX: Record<string, RegExp> = {
|
||||||
|
"nl-BE": /^Status wijzigen naar/,
|
||||||
|
"en-GB": /^Change status to/,
|
||||||
|
"fr-BE": /^Changer le statut vers/,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MANUAL_REVIEW_HEADING: Record<string, string> = {
|
||||||
|
"nl-BE": "Handmatige beoordeling vereist",
|
||||||
|
"en-GB": "Manual review required",
|
||||||
|
"fr-BE": "Évaluation manuelle requise",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loginAsOpsManager(page: Page, lang: string) {
|
||||||
|
await page.addInitScript((l) => localStorage.setItem("fleetops.language", l), lang);
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER[lang] }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("branding", () => {
|
||||||
|
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
|
||||||
|
test(`Fleet Ops is the visible brand and no MobilityOps/PoC leaks through (${lang})`, async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, lang);
|
||||||
|
await expect(page.locator(".brand-mark").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("Fleet Ops", { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.locator(".app-footer")).toContainText("Fleet Ops");
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
||||||
|
|
||||||
|
for (const path of ["/dashboard", "/vehicles", "/data-quality", "/audit", "/automation", "/knowledge"]) {
|
||||||
|
await page.goto(path);
|
||||||
|
const text = await page.locator("body").innerText();
|
||||||
|
expect(text, `${path} (${lang})`).not.toContain("MobilityOps");
|
||||||
|
expect(text, `${path} (${lang})`).not.toMatch(/\bPoC\b/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await page.goto("/login");
|
||||||
|
const href = await page.locator('link[rel="icon"]').getAttribute("href");
|
||||||
|
expect(href).toBe("/favicon.svg");
|
||||||
|
const response = await page.request.get(href as string);
|
||||||
|
expect(response.ok()).toBeTruthy();
|
||||||
|
expect(response.headers()["content-type"]).toContain("svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
||||||
|
// nl-BE on every reload, defeating exactly the persistence behaviour under test.
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER["nl-BE"] }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
await expect(page.getByRole("heading", { name: "Aandachtspunten" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("combobox", { name: "Taal" }).selectOption("fr-BE");
|
||||||
|
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("status-recommendation flow", () => {
|
||||||
|
test("preview does not mutate anything, apply names the exact target status", async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, "nl-BE");
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||||
|
await expect(page.getByText("Aanbevolen status")).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Waarom" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Gevolg" })).toBeVisible();
|
||||||
|
|
||||||
|
// Previewing must not have resolved the issue -- still open, using the page's own
|
||||||
|
// authenticated session (page.request shares cookies with the browser context).
|
||||||
|
const issue = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
|
||||||
|
expect((await issue.json()).status).toBe("open");
|
||||||
|
|
||||||
|
const confirmButton = page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] });
|
||||||
|
await expect(confirmButton).toHaveText(/Geblokkeerd/);
|
||||||
|
await confirmButton.click();
|
||||||
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
const resolved = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
|
||||||
|
expect((await resolved.json()).status).toBe("resolved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual review state offers no generic apply button for a genuine fact contradiction", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
const issues = await (
|
||||||
|
await request.get("/api/v1/data-quality/issues", {
|
||||||
|
params: { rule_type: "vehicle_status_conflict", status: "open" },
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
const conflicted = issues.find((i: { entity_ref: string }) => i.entity_ref === "MO-024");
|
||||||
|
expect(conflicted, "expected MO-024's vehicle_status_conflict issue to exist after reset").toBeTruthy();
|
||||||
|
|
||||||
|
await loginAsOpsManager(page, "en-GB");
|
||||||
|
await page.goto(`/data-quality/${conflicted.public_ref}`);
|
||||||
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: MANUAL_REVIEW_HEADING["en-GB"] })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^Change status to/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale recommendation is rejected and the user must review again before applying", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, "en-GB");
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
|
||||||
|
await expect(page.getByRole("button", { name: /^Change status to/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Simulate the underlying facts changing after the preview was shown (the same
|
||||||
|
// session resolves the booking overlap in the meantime) -- the previously-fetched
|
||||||
|
// recommendation token must no longer be accepted.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await fetch("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ booking_ref: "BK-DEMO-OVERLAP-B" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /^Change status to/ }).click();
|
||||||
|
await expect(page.getByText(/situation has changed/i)).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("MO-016 status conflict is order-independent", () => {
|
||||||
|
// Order independence does NOT mean "the same final vehicle status regardless of
|
||||||
|
// order" -- resolving the booking overlap first genuinely removes the conflict, so
|
||||||
|
// there is correctly nothing left to apply afterwards. 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").
|
||||||
|
|
||||||
|
test("resolving the booking overlap first correctly leaves nothing to apply", async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, "nl-BE");
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||||
|
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
|
||||||
|
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
|
||||||
|
await expect(page.getByText("Opgelost").first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Geen wijziging nodig" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] })).toHaveCount(0);
|
||||||
|
|
||||||
|
const vehicle = await request.get("/api/v1/vehicles/MO-016");
|
||||||
|
expect((await vehicle.json()).operational_status).toBe("available");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolving the status conflict first safely blocks the vehicle, unaffected by the later overlap fix", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, "nl-BE");
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||||
|
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
|
||||||
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
const vehicleMid = await request.get("/api/v1/vehicles/MO-016");
|
||||||
|
const statusAfterApply = (await vehicleMid.json()).operational_status;
|
||||||
|
expect(statusAfterApply).not.toBe("rented");
|
||||||
|
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||||
|
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
|
||||||
|
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
|
||||||
|
await expect(page.getByText("Opgelost").first()).toBeVisible();
|
||||||
|
|
||||||
|
// Resolving the now-redundant overlap afterwards must not itself change the
|
||||||
|
// vehicle's status as a side effect.
|
||||||
|
const vehicleFinal = await request.get("/api/v1/vehicles/MO-016");
|
||||||
|
const statusFinal = (await vehicleFinal.json()).operational_status;
|
||||||
|
expect(statusFinal).toBe(statusAfterApply);
|
||||||
|
expect(statusFinal).not.toBe("rented");
|
||||||
|
|
||||||
|
await resetDemoData(request);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("knowledge base is grounded in the operator's own language", () => {
|
||||||
|
const cases: { lang: string; question: string; sourceHint: RegExp }[] = [
|
||||||
|
{
|
||||||
|
lang: "nl-BE",
|
||||||
|
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||||
|
sourceHint: /schadeafhandeling/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "en-GB",
|
||||||
|
question: "What should I do when a vehicle returns with damage?",
|
||||||
|
sourceHint: /damage/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lang: "fr-BE",
|
||||||
|
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
|
||||||
|
sourceHint: /dommages/i,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { lang, question, sourceHint } of cases) {
|
||||||
|
test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, lang);
|
||||||
|
await page.goto("/knowledge");
|
||||||
|
await page.locator("#knowledge-question").fill(question);
|
||||||
|
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
|
||||||
|
await expect(page.getByText(sourceHint).first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("audit trail shows localized action and field labels with raw codes only in technical details", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, "nl-BE");
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||||
|
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
|
||||||
|
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/audit");
|
||||||
|
|
||||||
|
// Applying resolves both the vehicle status and the issue in one correlated action --
|
||||||
|
// expand the group's "technical events" toggle so the other audit row's diff renders
|
||||||
|
// too, regardless of which one the grouping picked as primary.
|
||||||
|
const toggle = page.getByRole("button", { name: /technische gebeurtenis/ }).first();
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.getByRole("button", { name: "Technische gebeurtenissen verbergen" })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText("Aanbevolen status toegepast").first()).toBeVisible();
|
||||||
|
const diffs = page.locator(".change-diff");
|
||||||
|
await expect(diffs.first()).toBeVisible();
|
||||||
|
const combinedDiffText = (await diffs.allInnerTexts()).join(" ");
|
||||||
|
expect(combinedDiffText).toContain("Operationele status");
|
||||||
|
expect(combinedDiffText).not.toContain("operational_status");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automation shows a localized error explanation with the raw error only under technical details", async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, "nl-BE");
|
||||||
|
await page.goto("/automation");
|
||||||
|
|
||||||
|
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
|
||||||
|
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
|
||||||
|
await page.getByText("Technische details").first().click();
|
||||||
|
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("route matrix (section 11F)", () => {
|
||||||
|
// Opens every main route in all 3 languages: no console errors, correct html[lang],
|
||||||
|
// and a real, non-empty page heading (proving the route actually rendered content
|
||||||
|
// instead of silently falling back to a raw i18next key or a blank screen). Key
|
||||||
|
// parity across locale files is already proven structurally by i18n-coverage.spec.ts
|
||||||
|
// (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this
|
||||||
|
// matrix focuses on what only a live render can catch.
|
||||||
|
const routes = [
|
||||||
|
"/dashboard",
|
||||||
|
"/vehicles",
|
||||||
|
"/vehicles/MO-001",
|
||||||
|
"/bookings",
|
||||||
|
"/bookings/BK-DEMO-RETURN",
|
||||||
|
"/data-quality",
|
||||||
|
"/data-quality/DQ-DEMO-STATUS",
|
||||||
|
"/automation",
|
||||||
|
"/knowledge",
|
||||||
|
"/audit",
|
||||||
|
"/scenarios",
|
||||||
|
"/about",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
|
||||||
|
test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
if (msg.type() !== "error") return;
|
||||||
|
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
|
||||||
|
errors.push(msg.text());
|
||||||
|
});
|
||||||
|
page.on("pageerror", (err) => errors.push(err.message));
|
||||||
|
|
||||||
|
await loginAsOpsManager(page, lang);
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
await page.goto(route);
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
||||||
|
const heading = page.getByRole("heading", { level: 1 });
|
||||||
|
await expect(heading, `${route} (${lang})`).toBeVisible();
|
||||||
|
const headingText = (await heading.first().textContent())?.trim() ?? "";
|
||||||
|
expect(headingText, `${route} (${lang}) heading text`).not.toBe("");
|
||||||
|
// A raw, unresolved i18next key looks like "namespace:some.key.path" -- real
|
||||||
|
// page headings never contain a colon followed by a dotted identifier.
|
||||||
|
expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch(
|
||||||
|
/^[a-zA-Z]+:[\w.]+$/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => {
|
||||||
|
// The primary evidence line at the top of every issue's detail page must render the
|
||||||
|
// structured `evidence.signals` in the operator's language; the legacy English
|
||||||
|
// `evidence.summary` string is a technical fallback only, visible solely inside
|
||||||
|
// "Technical details". Live-caught: this line was unconditionally showing raw
|
||||||
|
// English ("vehicle marked available while reserved bookings conflict") in every
|
||||||
|
// language until fixed.
|
||||||
|
const cases: { lang: string; expectedText: RegExp }[] = [
|
||||||
|
{ lang: "nl-BE", expectedText: /overlappende reserveringen/i },
|
||||||
|
{ lang: "en-GB", expectedText: /overlapping bookings/i },
|
||||||
|
{ lang: "fr-BE", expectedText: /chevauchent|chevauchement/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { lang, expectedText } of cases) {
|
||||||
|
test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => {
|
||||||
|
await resetDemoData(request);
|
||||||
|
await loginAsOpsManager(page, lang);
|
||||||
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
|
||||||
|
const summarySection = page.locator(".record-surface-evidence");
|
||||||
|
await expect(summarySection).toBeVisible();
|
||||||
|
await expect(summarySection).toContainText(expectedText);
|
||||||
|
await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
|
||||||
|
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
|
||||||
|
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
|
||||||
|
// control the browser's Date without waiting on real wall-clock time. Also proves the
|
||||||
|
// greeting updates live (no reload) when the period rolls over while the app stays open.
|
||||||
|
|
||||||
|
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
|
||||||
|
function cet(hour: number, minute = 0, second = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
|
||||||
|
{ time: cet(4, 59), label: "04:59", period: "night" },
|
||||||
|
{ time: cet(5, 0), label: "05:00", period: "morning" },
|
||||||
|
{ time: cet(11, 59), label: "11:59", period: "morning" },
|
||||||
|
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
|
||||||
|
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
|
||||||
|
{ time: cet(18, 0), label: "18:00", period: "evening" },
|
||||||
|
{ time: cet(22, 59), label: "22:59", period: "evening" },
|
||||||
|
{ time: cet(23, 0), label: "23:00", period: "night" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
|
||||||
|
"nl-BE": {
|
||||||
|
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
|
||||||
|
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
|
||||||
|
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
|
||||||
|
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
|
||||||
|
},
|
||||||
|
"en-GB": {
|
||||||
|
morning: "Good morning. Here's today's fleet status.",
|
||||||
|
afternoon: "Good afternoon. Here's the current overview of your fleet.",
|
||||||
|
evening: "Good evening. Here's this evening's fleet overview.",
|
||||||
|
night: "Welcome back. Here's the latest overview of your fleet.",
|
||||||
|
},
|
||||||
|
"fr-BE": {
|
||||||
|
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
|
||||||
|
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
|
||||||
|
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
|
||||||
|
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loginAsOperationsManager(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
|
||||||
|
// At the default desktop viewport, only the topbar's compact switcher is visible --
|
||||||
|
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
|
||||||
|
await page.locator(".language-switcher-compact select").selectOption(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
|
||||||
|
|
||||||
|
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
|
||||||
|
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
|
||||||
|
await page.clock.install({ time: BOUNDARY_CASES[0].time });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
if (language !== "nl-BE") {
|
||||||
|
await switchLanguage(page, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { time, label, period } of BOUNDARY_CASES) {
|
||||||
|
await page.clock.setFixedTime(time);
|
||||||
|
await page.reload();
|
||||||
|
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
|
||||||
|
EXPECTED_TITLE[language][period],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
|
||||||
|
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
|
||||||
|
await page.clock.install({ time: cet(11, 59, 31) });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
|
||||||
|
|
||||||
|
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
|
||||||
|
// recomputes the period -- no page.reload() call anywhere in this test.
|
||||||
|
await page.clock.fastForward(30_000);
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
|
||||||
|
await page.clock.install({ time: cet(9, 0) });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
|
||||||
|
|
||||||
|
await switchLanguage(page, "en-GB");
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
|
||||||
|
|
||||||
|
await switchLanguage(page, "fr-BE");
|
||||||
|
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
|
||||||
|
await page.clock.install({ time: cet(2, 0) });
|
||||||
|
await loginAsOperationsManager(page);
|
||||||
|
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
|
||||||
|
await expect(dashboardHeading(page)).toContainText("Welkom terug");
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
|
||||||
|
|
||||||
|
// Pure Node-context boundary tests for the central, clock-injectable greeting function
|
||||||
|
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
|
||||||
|
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
|
||||||
|
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
|
||||||
|
// by picking instants either side of the CET/CEST transition.
|
||||||
|
|
||||||
|
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
|
||||||
|
function cet(hour: number, minute = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
|
||||||
|
function cest(hour: number, minute = 0): Date {
|
||||||
|
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
|
||||||
|
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
|
||||||
|
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
|
||||||
|
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
|
||||||
|
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
|
||||||
|
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
|
||||||
|
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
|
||||||
|
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
|
||||||
|
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
|
||||||
|
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
|
||||||
|
expect(getBrusselsHour(beforeTransition)).toBe(1);
|
||||||
|
expect(getGreetingPeriod(beforeTransition)).toBe("night");
|
||||||
|
|
||||||
|
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
|
||||||
|
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
|
||||||
|
expect(getBrusselsHour(afterTransition)).toBe(11);
|
||||||
|
expect(getGreetingPeriod(afterTransition)).toBe("morning");
|
||||||
|
|
||||||
|
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
|
||||||
|
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
|
||||||
|
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
|
||||||
|
expect(getBrusselsHour(beforeFallBack)).toBe(2);
|
||||||
|
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
|
||||||
|
|
||||||
|
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
|
||||||
|
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
|
||||||
|
expect(getBrusselsHour(afterFallBack)).toBe(10);
|
||||||
|
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default argument uses the real current time when no clock is injected", () => {
|
||||||
|
const period = getGreetingPeriod();
|
||||||
|
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
|
||||||
|
});
|
||||||
@@ -71,3 +71,253 @@ test("no locale file contains an empty string value", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value ---
|
||||||
|
// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1).
|
||||||
|
// A regression here means someone re-introduced a per-locale brand key/value instead of
|
||||||
|
// interpolating {{productName}} from the shared constant.
|
||||||
|
|
||||||
|
test("no locale file defines an 'appName' key or the literal brand string", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const data = loadNamespace(language, namespace);
|
||||||
|
const raw = JSON.stringify(data);
|
||||||
|
expect(
|
||||||
|
raw.includes("Fleet Ops"),
|
||||||
|
`${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` +
|
||||||
|
`use {{productName}} interpolation instead so the brand can never drift per locale`,
|
||||||
|
).toBe(false);
|
||||||
|
const keys = collectKeyPaths(data);
|
||||||
|
expect(
|
||||||
|
keys.some((k) => k === "appName" || k.endsWith(".appName")),
|
||||||
|
`${language}/${namespace}.json defines an "appName" key -- the brand name must come ` +
|
||||||
|
`from the PRODUCT_NAME constant, never a translatable key`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const raw = JSON.stringify(loadNamespace(language, namespace));
|
||||||
|
expect(
|
||||||
|
raw.includes("MobilityOps"),
|
||||||
|
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
|
||||||
|
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
/\bPoC\b/.test(raw),
|
||||||
|
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
|
||||||
|
`in user-facing copy`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
||||||
|
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
||||||
|
// the literal English string under the right key and still pass). For every "real prose"
|
||||||
|
// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from
|
||||||
|
// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and
|
||||||
|
// "Dutch text copy-pasted into French" in one pass.
|
||||||
|
|
||||||
|
// Exact (namespace, key-path) pairs that are legitimately identical across two or more
|
||||||
|
// locales: real proper nouns/brand names, deliberately-untranslated role titles, and
|
||||||
|
// genuine cross-language cognates (identical spelling in Dutch/French/English). This is
|
||||||
|
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
||||||
|
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||||
|
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
||||||
|
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
||||||
|
"common.language.nl-BE", // language-picker options show each language's own endonym
|
||||||
|
"common.language.fr-BE",
|
||||||
|
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||||
|
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||||
|
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||||
|
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
||||||
|
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
||||||
|
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||||
|
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
||||||
|
"fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French
|
||||||
|
"integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French
|
||||||
|
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
||||||
|
"knowledge.retrievalFlow.question",
|
||||||
|
"knowledge.questionLabelExchange",
|
||||||
|
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTranslatableProse(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
if (value.trim().length < 8) return false;
|
||||||
|
// Strip interpolation placeholders and non-letter characters; if nothing substantial
|
||||||
|
// remains (pure numbers/punctuation/units), it's not "prose" that needs translating.
|
||||||
|
const stripped = value
|
||||||
|
.replace(/\{\{[^}]+\}\}/g, " ")
|
||||||
|
.replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, "");
|
||||||
|
return stripped.trim().length >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const en = loadNamespace("en-GB", namespace);
|
||||||
|
const nl = loadNamespace("nl-BE", namespace);
|
||||||
|
const fr = loadNamespace("fr-BE", namespace);
|
||||||
|
const keys = collectKeyPaths(en);
|
||||||
|
|
||||||
|
for (const keyPath of keys) {
|
||||||
|
if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue;
|
||||||
|
const at = (data: Record<string, unknown>) =>
|
||||||
|
keyPath.split(".").reduce<unknown>((acc, part) => {
|
||||||
|
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||||
|
return undefined;
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
const enValue = at(en);
|
||||||
|
if (!isTranslatableProse(enValue)) continue;
|
||||||
|
const nlValue = at(nl);
|
||||||
|
const frValue = at(fr);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
nlValue,
|
||||||
|
`${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` +
|
||||||
|
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
||||||
|
).not.toBe(enValue);
|
||||||
|
expect(
|
||||||
|
frValue,
|
||||||
|
`${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` +
|
||||||
|
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
||||||
|
).not.toBe(enValue);
|
||||||
|
expect(
|
||||||
|
frValue,
|
||||||
|
`${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` +
|
||||||
|
`looks like Dutch text was copy-pasted into the French locale`,
|
||||||
|
).not.toBe(nlValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
|
||||||
|
// The whole-string identity check above only catches a value that is IDENTICAL to
|
||||||
|
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
|
||||||
|
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
|
||||||
|
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
|
||||||
|
// goedgekeurd." This scan flags known English fragments appearing literally inside any
|
||||||
|
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
|
||||||
|
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
|
||||||
|
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
|
||||||
|
const FORBIDDEN_ENGLISH_FRAGMENTS = [
|
||||||
|
"Operations Manager",
|
||||||
|
"Operations Managers",
|
||||||
|
"Rental Employee",
|
||||||
|
"Rental Employees",
|
||||||
|
"Audit trail",
|
||||||
|
"Start scenario",
|
||||||
|
];
|
||||||
|
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
|
||||||
|
"Operationsmanager",
|
||||||
|
"Verhuurmedewerker",
|
||||||
|
"Auditgeschiedenis",
|
||||||
|
"Scenario starten",
|
||||||
|
];
|
||||||
|
|
||||||
|
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
|
||||||
|
if (typeof value === "string") return [{ path: prefix, value }];
|
||||||
|
if (value === null || typeof value !== "object") return [];
|
||||||
|
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||||
|
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
|
||||||
|
const findings: string[] = [];
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const nl = loadNamespace("nl-BE", namespace);
|
||||||
|
const fr = loadNamespace("fr-BE", namespace);
|
||||||
|
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
|
||||||
|
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
|
||||||
|
if (value.includes(fragment)) {
|
||||||
|
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
|
||||||
|
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
|
||||||
|
if (value.includes(fragment)) {
|
||||||
|
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(findings, findings.join("\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Hardcoded JSX text (section 11D) ---
|
||||||
|
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
||||||
|
// `{...}` expression) containing two or more real words are almost always user-facing
|
||||||
|
// prose that should go through t(...). This is not a full parser, so a short, explicit
|
||||||
|
// allowlist covers technical tokens/proper nouns that are correctly never translated
|
||||||
|
// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE
|
||||||
|
// expected to appear literally in JSX).
|
||||||
|
const SRC_DIR = path.resolve(__dirname, "../src");
|
||||||
|
const SCAN_DIRS = ["pages", "components"];
|
||||||
|
|
||||||
|
const ALLOWED_LITERAL_TEXT = new Set([
|
||||||
|
"Fleet Ops", // the non-localizable brand name (frontend/src/product.ts)
|
||||||
|
"Northstar Mobility", // fictional demo org, a proper noun
|
||||||
|
"ITWorx MCP Hub", // proper noun
|
||||||
|
]);
|
||||||
|
|
||||||
|
function collectTsxFiles(dir: string): string[] {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return collectTsxFiles(full);
|
||||||
|
return entry.name.endsWith(".tsx") ? [full] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHardcodedJsxText(filePath: string): string[] {
|
||||||
|
const source = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const findings: string[] = [];
|
||||||
|
// Matches `<Tag ...>text</Tag>` where the closing tag name backreferences the
|
||||||
|
// opening one -- this specifically excludes TypeScript generics like
|
||||||
|
// `useState<string | null>(null)`, which have no matching `</string | null>` closer,
|
||||||
|
// unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly
|
||||||
|
// puts JSX text on its own line) and does not attempt to parse JSX properly -- it is
|
||||||
|
// a fast, approximate net for the common mistake, not a compiler.
|
||||||
|
const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = jsxTextPattern.exec(source)) !== null) {
|
||||||
|
const text = match[2].trim();
|
||||||
|
if (!text) continue;
|
||||||
|
if (ALLOWED_LITERAL_TEXT.has(text)) continue;
|
||||||
|
// Needs at least two alphabetic words to count as "prose" -- filters out numbers,
|
||||||
|
// single technical words, units (km, %), punctuation-only fragments, and JSX
|
||||||
|
// whitespace artifacts.
|
||||||
|
const words = text.match(/[A-Za-z]+/g) ?? [];
|
||||||
|
if (words.length < 2) continue;
|
||||||
|
// Skip anything that is itself an i18next interpolation artifact leaking through
|
||||||
|
// (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL
|
||||||
|
// or path.
|
||||||
|
if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue;
|
||||||
|
findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`);
|
||||||
|
}
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => {
|
||||||
|
const allFindings: string[] = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
const files = collectTsxFiles(path.join(SRC_DIR, dir));
|
||||||
|
for (const file of files) {
|
||||||
|
allFindings.push(...findHardcodedJsxText(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
allFindings,
|
||||||
|
`Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` +
|
||||||
|
`Either route it through the translation system, or add the exact literal to ` +
|
||||||
|
`ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` +
|
||||||
|
allFindings.join("\n"),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -130,9 +130,13 @@ test("return preview correctly reports blocked (not maintenance) for damage repo
|
|||||||
|
|
||||||
// The preview is the server's authoritative evaluation: damage always routes to
|
// The preview is the server's authoritative evaluation: damage always routes to
|
||||||
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
|
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
|
||||||
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
|
// The localized reason is the primary text; the raw code sits behind "Technical
|
||||||
|
// details" so it isn't visible until expanded.
|
||||||
|
await expect(page.getByText("Damage was reported on this return.")).toBeVisible();
|
||||||
const statusRegion = page.locator(".impact-preview");
|
const statusRegion = page.locator(".impact-preview");
|
||||||
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
|
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
|
||||||
|
await page.getByText("Technical details").click();
|
||||||
|
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("data quality page: status and rule-type filters work", async ({ page }) => {
|
test("data quality page: status and rule-type filters work", async ({ page }) => {
|
||||||
@@ -201,8 +205,8 @@ test("data quality: applying the recommended status resolves a vehicle conflict"
|
|||||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Calculate and apply recommended status" }).click();
|
await page.getByRole("button", { name: "Review recommendation" }).click();
|
||||||
await page.getByRole("button", { name: "Yes, apply" }).click();
|
await page.getByRole("button", { name: /^Change status to/ }).click();
|
||||||
|
|
||||||
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
|
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Fleet Ops</title>
|
<title>Fleet Ops</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#0f172a" />
|
||||||
|
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="white" />
|
||||||
|
<path d="M3 15.5h26" stroke="#2dd4bf" stroke-width="2.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
@@ -0,0 +1,12 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
code: string;
|
||||||
|
correlationId: string;
|
||||||
|
|
||||||
|
constructor(status: number, code: string, message: string, correlationId: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.correlationId = correlationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { ApiError } from "./apiError";
|
||||||
|
|
||||||
|
export { ApiError } from "./apiError";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type UnauthorizedListener = () => void;
|
type UnauthorizedListener = () => void;
|
||||||
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
|||||||
return () => unauthorizedListeners.delete(listener);
|
return () => unauthorizedListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
status: number;
|
|
||||||
code: string;
|
|
||||||
correlationId: string;
|
|
||||||
|
|
||||||
constructor(status: number, code: string, message: string, correlationId: string) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
this.code = code;
|
|
||||||
this.correlationId = correlationId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { ApiError } from "./apiError";
|
||||||
|
|
||||||
|
export interface ApiErrorInfo {
|
||||||
|
title: string;
|
||||||
|
explanation: string;
|
||||||
|
nextStep?: string;
|
||||||
|
technical: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
// Backend AppError codes this frontend knows how to present with a localized title,
|
||||||
|
// explanation and (where useful) a next step -- see
|
||||||
|
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
|
||||||
|
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
|
||||||
|
// a newly-introduced backend code never regresses to raw English -- it just falls back
|
||||||
|
// to a generic-but-localized message until this list is extended.
|
||||||
|
export const KNOWN_CODES = new Set([
|
||||||
|
"VEHICLE_NOT_FOUND",
|
||||||
|
"BOOKING_NOT_FOUND",
|
||||||
|
"CUSTOMER_NOT_FOUND",
|
||||||
|
"ENTITY_NOT_FOUND",
|
||||||
|
"EVENT_NOT_FOUND",
|
||||||
|
"ISSUE_NOT_FOUND",
|
||||||
|
"ISSUE_NOT_OPEN",
|
||||||
|
"BOOKING_NOT_ACTIVE",
|
||||||
|
"INVALID_BOOKING_STATE",
|
||||||
|
"NOT_RETRYABLE",
|
||||||
|
"CONFLICT_STILL_PRESENT",
|
||||||
|
"OVERLAP_STILL_PRESENT",
|
||||||
|
"EMPTY_VALUE",
|
||||||
|
"NO_FIELDS_PROVIDED",
|
||||||
|
"INVALID_FIELD",
|
||||||
|
"INVALID_FIELD_OVERRIDE",
|
||||||
|
"INVALID_SURVIVOR",
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"INVALID_EVENT_ID",
|
||||||
|
"INVALID_IDEMPOTENCY_KEY",
|
||||||
|
"IDEMPOTENCY_KEY_REUSED",
|
||||||
|
"CORRECTED_VALUE_REQUIRED",
|
||||||
|
"CORRECTION_BELOW_CANONICAL",
|
||||||
|
"NOT_A_DUPLICATE_ISSUE",
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE",
|
||||||
|
"NOT_AN_ODOMETER_ISSUE",
|
||||||
|
"NOT_AN_OVERLAP_ISSUE",
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||||
|
"UNSUPPORTED_ENTITY",
|
||||||
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
|
"NO_CONFLICT_DETECTED",
|
||||||
|
"RECOMMENDATION_STALE",
|
||||||
|
"UNAUTHORIZED_SERVICE",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
|
||||||
|
* for display. The raw backend/network text is only ever exposed as `technical`
|
||||||
|
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
|
||||||
|
*
|
||||||
|
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
|
||||||
|
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
|
||||||
|
* existed) and errors:generic doesn't fit the specific action being attempted.
|
||||||
|
*/
|
||||||
|
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
|
||||||
|
if (!(err instanceof ApiError)) {
|
||||||
|
return {
|
||||||
|
title: t("errors:generic.title"),
|
||||||
|
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
|
||||||
|
technical: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KNOWN_CODES.has(err.code)) {
|
||||||
|
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
|
||||||
|
return {
|
||||||
|
title: t(`errors:codes.${err.code}.title`),
|
||||||
|
explanation: t(`errors:codes.${err.code}.explanation`),
|
||||||
|
nextStep: nextStep || undefined,
|
||||||
|
technical: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
|
||||||
|
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
|
||||||
|
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
|
||||||
|
return {
|
||||||
|
title: t(`errors:http.${httpKey}.title`),
|
||||||
|
explanation: t(`errors:http.${httpKey}.explanation`),
|
||||||
|
nextStep: nextStep || undefined,
|
||||||
|
technical: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t("errors:generic.title"),
|
||||||
|
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
|
||||||
|
technical: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ export interface AutomationRun {
|
|||||||
status: string;
|
status: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
|
last_error_code: string | null;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,8 @@ export interface ReturnPreviewResult {
|
|||||||
resulting_odometer_km: number;
|
resulting_odometer_km: number;
|
||||||
resulting_vehicle_status: string;
|
resulting_vehicle_status: string;
|
||||||
status_reason: string;
|
status_reason: string;
|
||||||
|
status_reason_code: string;
|
||||||
|
status_reason_params: Record<string, string | number>;
|
||||||
would_create_quality_issue: boolean;
|
would_create_quality_issue: boolean;
|
||||||
attention_reasons: string[];
|
attention_reasons: string[];
|
||||||
next_booking_risk: NextBookingRisk | null;
|
next_booking_risk: NextBookingRisk | null;
|
||||||
@@ -173,7 +176,27 @@ export interface DataQualityIssueDetail extends DataQualityIssue {
|
|||||||
export interface ApplyRecommendedStatusResult {
|
export interface ApplyRecommendedStatusResult {
|
||||||
issue: DataQualityIssue;
|
issue: DataQualityIssue;
|
||||||
applied_status: string;
|
applied_status: string;
|
||||||
reason: string;
|
reason_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleStatusFacts {
|
||||||
|
active_booking_refs: string[];
|
||||||
|
overlapping_booking_pairs: string[][];
|
||||||
|
service_threshold_reached: boolean;
|
||||||
|
odometer_km: number;
|
||||||
|
next_service_km: number;
|
||||||
|
open_booking_overlap_issue_ref: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusRecommendation {
|
||||||
|
current_status: string;
|
||||||
|
recommended_status: string | null;
|
||||||
|
recommendation_code: string;
|
||||||
|
safe_to_apply: boolean;
|
||||||
|
manual_review_required: boolean;
|
||||||
|
facts: VehicleStatusFacts;
|
||||||
|
blocking_reasons: string[];
|
||||||
|
recommendation_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
@@ -183,7 +206,8 @@ export interface ScanResult {
|
|||||||
export interface SearchResultItem {
|
export interface SearchResultItem {
|
||||||
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
||||||
label: string;
|
label: string;
|
||||||
detail: string;
|
detail_code: string;
|
||||||
|
detail_params: Record<string, string>;
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +254,18 @@ export interface KnowledgeHealth {
|
|||||||
document_count: number;
|
document_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface N8nWorkflowEvidence {
|
||||||
|
name: string;
|
||||||
|
built: boolean;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface N8nErrorHandlerStatus {
|
||||||
|
total_failures_registered: number;
|
||||||
|
latest_failure_at: string | null;
|
||||||
|
latest_failure_workflow: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface N8nIntegrationStatus {
|
export interface N8nIntegrationStatus {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
dispatch_enabled: boolean;
|
dispatch_enabled: boolean;
|
||||||
@@ -240,6 +276,10 @@ export interface N8nIntegrationStatus {
|
|||||||
succeeded: number;
|
succeeded: number;
|
||||||
latest_success_at: string | null;
|
latest_success_at: string | null;
|
||||||
latest_failure_at: string | null;
|
latest_failure_at: string | null;
|
||||||
|
expected_workflow_count: number;
|
||||||
|
known_workflow_count: number;
|
||||||
|
workflows: N8nWorkflowEvidence[];
|
||||||
|
error_handler: N8nErrorHandlerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpHubIntegrationStatus {
|
export interface McpHubIntegrationStatus {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { useViewportTier } from "../hooks/useViewportTier";
|
import { useViewportTier } from "../hooks/useViewportTier";
|
||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
export function DemoGuideTrigger() {
|
export function DemoGuideTrigger() {
|
||||||
const { t } = useTranslation("demo");
|
const { t } = useTranslation("demo");
|
||||||
@@ -67,7 +70,7 @@ export function DemoGuide() {
|
|||||||
setCollapsedToChip,
|
setCollapsedToChip,
|
||||||
} = useDemoGuide();
|
} = useDemoGuide();
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [resetError, setResetError] = useState<string | null>(null);
|
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||||
const pendingTarget = useRef<string | null>(null);
|
const pendingTarget = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -118,7 +121,7 @@ export function DemoGuide() {
|
|||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
setResetError(describeApiError(t, err, "guide.restartFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
}
|
}
|
||||||
@@ -206,7 +209,7 @@ export function DemoGuide() {
|
|||||||
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
||||||
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
||||||
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
||||||
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
|
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`, { productName: PRODUCT_NAME })}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -226,7 +229,7 @@ export function DemoGuide() {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
<ApiErrorNotice error={resetError} />
|
||||||
|
|
||||||
<footer className="demo-guide-footer">
|
<footer className="demo-guide-footer">
|
||||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import type { Role, SearchResultItem } from "../api/types";
|
import type { Role, SearchResultItem } from "../api/types";
|
||||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||||
@@ -10,6 +11,8 @@ import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
|||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
|
||||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||||
vehicle: "fleet",
|
vehicle: "fleet",
|
||||||
@@ -18,6 +21,31 @@ const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
|||||||
section: "chevron",
|
section: "chevron",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The backend only ever sends a stable code + raw data params (never English prose) --
|
||||||
|
// see app/api/routers/search.py. Localizing here means the label/detail always follow
|
||||||
|
// the operator's selected locale, in every namespace search results can point to.
|
||||||
|
function searchResultLabel(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
|
||||||
|
if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label });
|
||||||
|
return item.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchResultDetail(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
|
||||||
|
switch (item.type) {
|
||||||
|
case "section":
|
||||||
|
return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code });
|
||||||
|
case "vehicle":
|
||||||
|
return t("searchVehicleSummary", { ...item.detail_params });
|
||||||
|
case "booking":
|
||||||
|
return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code });
|
||||||
|
case "data_quality_issue":
|
||||||
|
return t(`quality:ruleTypes.${item.detail_code}`, {
|
||||||
|
defaultValue: item.detail_code.replace(/_/g, " "),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return item.detail_code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -60,7 +88,7 @@ export function Layout() {
|
|||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const [resetConfirming, setResetConfirming] = useState(false);
|
const [resetConfirming, setResetConfirming] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [resetError, setResetError] = useState<string | null>(null);
|
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||||
const searchInput = useRef<HTMLInputElement>(null);
|
const searchInput = useRef<HTMLInputElement>(null);
|
||||||
const searchBox = useRef<HTMLDivElement>(null);
|
const searchBox = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -137,7 +165,7 @@ export function Layout() {
|
|||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
|
setResetError(describeApiError(t, err, "resetFailed"));
|
||||||
setResetConfirming(false);
|
setResetConfirming(false);
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
@@ -177,7 +205,7 @@ export function Layout() {
|
|||||||
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
||||||
<div className="brand-lockup">
|
<div className="brand-lockup">
|
||||||
<BrandMark className="brand-mark" />
|
<BrandMark className="brand-mark" />
|
||||||
<div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
|
<div><strong>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-language">
|
<div className="sidebar-language">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
@@ -205,7 +233,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||||
<div className="sidebar-reset">
|
<div className="sidebar-reset">
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
<ApiErrorNotice error={resetError} />
|
||||||
{!resetConfirming ? (
|
{!resetConfirming ? (
|
||||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||||
{t("resetDemoData")}
|
{t("resetDemoData")}
|
||||||
@@ -234,7 +262,7 @@ export function Layout() {
|
|||||||
</button>
|
</button>
|
||||||
<div className="global-search" role="search" ref={searchBox}>
|
<div className="global-search" role="search" ref={searchBox}>
|
||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
|
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
|
||||||
<input
|
<input
|
||||||
id="global-search-input"
|
id="global-search-input"
|
||||||
ref={searchInput}
|
ref={searchInput}
|
||||||
@@ -276,8 +304,8 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<Icon name={SEARCH_ICON[item.type]} />
|
<Icon name={SEARCH_ICON[item.type]} />
|
||||||
<span className="search-result-copy">
|
<span className="search-result-copy">
|
||||||
<strong>{item.label}</strong>
|
<strong>{searchResultLabel(t, item)}</strong>
|
||||||
<small>{item.detail}</small>
|
<small>{searchResultDetail(t, item)}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -302,7 +330,7 @@ export function Layout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||||
<footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
|
<footer className="app-footer"><span>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared rendering for describeApiError()'s output: a localized title + explanation +
|
||||||
|
// optional next step, with the raw backend/network text demoted to a "Technical
|
||||||
|
// details" disclosure -- never shown as the primary message. See
|
||||||
|
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
|
||||||
|
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<div className="error api-error-notice" role="alert">
|
||||||
|
<strong>{error.title}</strong>
|
||||||
|
<p>{error.explanation}</p>
|
||||||
|
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
|
||||||
|
<details className="evidence-disclosure">
|
||||||
|
<summary>{t("actions.technicalDetails")}</summary>
|
||||||
|
<pre className="evidence-block">{error.technical}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
icon = "check",
|
icon = "check",
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
|
|||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
import { StatusBadge } from "./Badge";
|
import { StatusBadge } from "./Badge";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
|
||||||
function newIdempotencyKey(): string {
|
function newIdempotencyKey(): string {
|
||||||
return typeof crypto.randomUUID === "function"
|
return typeof crypto.randomUUID === "function"
|
||||||
@@ -103,7 +105,7 @@ export function ReturnForm({
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [previewing, setPreviewing] = useState(false);
|
const [previewing, setPreviewing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
||||||
@@ -132,7 +134,7 @@ export function ReturnForm({
|
|||||||
setPreview(evaluated);
|
setPreview(evaluated);
|
||||||
setStep("review");
|
setStep("review");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
setError(describeApiError(t, err));
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewing(false);
|
setPreviewing(false);
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@ export function ReturnForm({
|
|||||||
);
|
);
|
||||||
onRegistered(registered);
|
onRegistered(registered);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
setError(describeApiError(t, err));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -158,7 +160,7 @@ export function ReturnForm({
|
|||||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
||||||
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
||||||
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
<ApiErrorNotice error={error} />
|
||||||
|
|
||||||
{step === "capture" ? <div className="return-capture">
|
{step === "capture" ? <div className="return-capture">
|
||||||
<div className="form-grid"><label>
|
<div className="form-grid"><label>
|
||||||
@@ -220,7 +222,17 @@ export function ReturnForm({
|
|||||||
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
|
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
|
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
|
||||||
<p>{preview.status_reason}</p>
|
<p>{t(`review.reasonCodes.${preview.status_reason_code}`, {
|
||||||
|
defaultValue: preview.status_reason,
|
||||||
|
threshold_km:
|
||||||
|
typeof preview.status_reason_params.threshold_km === "number"
|
||||||
|
? formatNumber(preview.status_reason_params.threshold_km)
|
||||||
|
: undefined,
|
||||||
|
})}</p>
|
||||||
|
<details className="evidence-disclosure">
|
||||||
|
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||||
|
<pre className="evidence-block">{preview.status_reason}</pre>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{preview.odometer_regression && (
|
{preview.odometer_regression && (
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { sta
|
|||||||
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
||||||
configured: { statusClass: "no_events", labelKey: "prepared" },
|
configured: { statusClass: "no_events", labelKey: "prepared" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a
|
||||||
|
// stable slug for localized display names in `integrations:workflows.names.*` — the
|
||||||
|
// backend name itself is a technical identifier, not something to show untranslated.
|
||||||
|
export const N8N_WORKFLOW_SLUGS: Record<string, string> = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": "vehicleReturn",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": "scheduledScan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": "ragcoreSync",
|
||||||
|
"Fleet Ops — Workflow Error Handler": "errorHandler",
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
|
||||||
|
|
||||||
|
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
|
||||||
|
|
||||||
|
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
|
||||||
|
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
|
||||||
|
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
|
||||||
|
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
|
||||||
|
export function getBrusselsHour(date: Date): number {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: BRUSSELS_TIME_ZONE,
|
||||||
|
hour: "numeric",
|
||||||
|
hourCycle: "h23",
|
||||||
|
});
|
||||||
|
return Number(formatter.format(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundaries per the Fleet Ops final localization brief (section 9):
|
||||||
|
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
|
||||||
|
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
|
||||||
|
// a fixed clock instead of depending on the real wall clock.
|
||||||
|
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
|
||||||
|
const hour = getBrusselsHour(date);
|
||||||
|
if (hour >= 5 && hour < 12) return "morning";
|
||||||
|
if (hour >= 12 && hour < 18) return "afternoon";
|
||||||
|
if (hour >= 18 && hour < 23) return "evening";
|
||||||
|
return "night";
|
||||||
|
}
|
||||||
@@ -40,6 +40,29 @@
|
|||||||
"was": "{{field}}: was {{value}}",
|
"was": "{{field}}: was {{value}}",
|
||||||
"changed": "{{field}}: {{before}} → {{after}}"
|
"changed": "{{field}}: {{before}} → {{after}}"
|
||||||
},
|
},
|
||||||
|
"actorTypes": {
|
||||||
|
"user": "User",
|
||||||
|
"system": "System",
|
||||||
|
"service": "Service"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"status": "Status",
|
||||||
|
"operational_status": "Operational status",
|
||||||
|
"registration_number": "Registration number",
|
||||||
|
"make": "Make",
|
||||||
|
"model": "Model",
|
||||||
|
"location": "Location",
|
||||||
|
"first_name": "First name",
|
||||||
|
"last_name": "Last name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"postal_code": "Postal code",
|
||||||
|
"city": "City",
|
||||||
|
"booking_end_odometer_km": "Booking end odometer",
|
||||||
|
"vehicle_odometer_km": "Vehicle odometer",
|
||||||
|
"survivor": "Retained profile",
|
||||||
|
"loser": "Merged profile"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Logged in",
|
"demo_login": "Logged in",
|
||||||
"demo_logout": "Logged out",
|
"demo_logout": "Logged out",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"orgLine": "Demo organisation: {{orgName}} (fictional)",
|
"orgLine": "Demo organisation: {{orgName}} (fictional)",
|
||||||
"headline1": "Every hand-off.",
|
"headline1": "Every hand-off.",
|
||||||
"headline2": "One clear view.",
|
"headline2": "One clear view.",
|
||||||
"defaultDescription": "Fleet Ops brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
|
"defaultDescription": "{{productName}} brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
|
||||||
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
|
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
|
||||||
"accessEyebrow": "Demo access",
|
"accessEyebrow": "Demo access",
|
||||||
"accessHeading": "Choose how to start",
|
"accessHeading": "Choose how to start",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appName": "Fleet Ops",
|
|
||||||
"orgName": "Northstar Mobility",
|
"orgName": "Northstar Mobility",
|
||||||
"brandTagline": "Control Centre",
|
"brandTagline": "Control Centre",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
"fr-BE": "Français"
|
"fr-BE": "Français"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"productLine": "Fleet Ops Demo",
|
"productLine": "{{productName}} Demo",
|
||||||
"locale": "Europe/Brussels · Synthetic demo data"
|
"locale": "Europe/Brussels · Synthetic demo data"
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Operations / Live overview",
|
"eyebrow": "Operations / Live overview",
|
||||||
"title": "Good morning. Here's the fleet.",
|
"greeting": {
|
||||||
|
"morning": "Good morning",
|
||||||
|
"afternoon": "Good afternoon",
|
||||||
|
"evening": "Good evening",
|
||||||
|
"night": "Welcome back"
|
||||||
|
},
|
||||||
|
"greetingBody": {
|
||||||
|
"morning": "Here's today's fleet status.",
|
||||||
|
"afternoon": "Here's the current overview of your fleet.",
|
||||||
|
"evening": "Here's this evening's fleet overview.",
|
||||||
|
"night": "Here's the latest overview of your fleet."
|
||||||
|
},
|
||||||
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
||||||
"viewFleet": "View fleet",
|
"viewFleet": "View fleet",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
|
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
|
||||||
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
|
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
|
||||||
"startAction": "Read the 'About this demo' page.",
|
"startAction": "Read the 'About this demo' page.",
|
||||||
"expectedOutcome": "You can explain yourself what Fleet Ops is and isn't, with no verbal explanation needed."
|
"expectedOutcome": "You can explain yourself what {{productName}} is and isn't, with no verbal explanation needed."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,16 +146,16 @@
|
|||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"eyebrow": "About this demo",
|
"eyebrow": "About this demo",
|
||||||
"title": "What Fleet Ops is and isn't",
|
"title": "What {{productName}} is and isn't",
|
||||||
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
||||||
"loading": "Loading demo information…",
|
"loading": "Loading demo information…",
|
||||||
"ctaTitle": "Prefer to jump right in?",
|
"ctaTitle": "Prefer to jump right in?",
|
||||||
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
||||||
"ctaButton": "Start guided demo",
|
"ctaButton": "Start guided demo",
|
||||||
"problemTitle": "The fictional problem",
|
"problemTitle": "The fictional problem",
|
||||||
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. Fleet Ops shows how one connected system flags these problems early and lets them be resolved under control.",
|
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
|
||||||
"scopeTitle": "Who it's for and its scope",
|
"scopeTitle": "Who it's for and its scope",
|
||||||
"scopeBody": "This demo is for anyone who wants to see how Fleet Ops tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
|
"scopeBody": "This demo is for anyone who wants to see how {{productName}} tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
|
||||||
"realTitle": "What really works",
|
"realTitle": "What really works",
|
||||||
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
|
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
|
||||||
"syntheticTitle": "What's synthetic",
|
"syntheticTitle": "What's synthetic",
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Something went wrong. Please try again.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "We couldn't load this workspace.",
|
"title": "Something went wrong",
|
||||||
"unauthorized": "Your session has expired. Please log in again.",
|
"explanation": "This action could not be completed. Please try again."
|
||||||
"forbidden": "You don't have access to this section.",
|
},
|
||||||
"notFound": "This record could not be found.",
|
"codes": {
|
||||||
"networkUnavailable": "The connection to the server is currently unavailable."
|
"VEHICLE_NOT_FOUND": {
|
||||||
|
"title": "Vehicle not found",
|
||||||
|
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_FOUND": {
|
||||||
|
"title": "Booking not found",
|
||||||
|
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
|
||||||
|
},
|
||||||
|
"CUSTOMER_NOT_FOUND": {
|
||||||
|
"title": "Customer not found",
|
||||||
|
"explanation": "This customer record could not be found."
|
||||||
|
},
|
||||||
|
"ENTITY_NOT_FOUND": {
|
||||||
|
"title": "Record not found",
|
||||||
|
"explanation": "The underlying record for this action could not be found."
|
||||||
|
},
|
||||||
|
"EVENT_NOT_FOUND": {
|
||||||
|
"title": "Workflow event not found",
|
||||||
|
"explanation": "This automation event could not be found."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_FOUND": {
|
||||||
|
"title": "Data-quality issue not found",
|
||||||
|
"explanation": "This data-quality issue could not be found."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_OPEN": {
|
||||||
|
"title": "Issue is no longer open",
|
||||||
|
"explanation": "This issue has already been resolved, deferred or rejected.",
|
||||||
|
"nextStep": "Refresh the page to see its current state."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_ACTIVE": {
|
||||||
|
"title": "Booking is not active",
|
||||||
|
"explanation": "Only a reserved or active booking can be used for this action."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_STATE": {
|
||||||
|
"title": "Booking is in the wrong state",
|
||||||
|
"explanation": "This booking's current state does not allow this action."
|
||||||
|
},
|
||||||
|
"NOT_RETRYABLE": {
|
||||||
|
"title": "This event cannot be retried",
|
||||||
|
"explanation": "Only a failed delivery can be retried."
|
||||||
|
},
|
||||||
|
"CONFLICT_STILL_PRESENT": {
|
||||||
|
"title": "Conflict was not resolved",
|
||||||
|
"explanation": "Applying this change did not resolve the underlying conflict.",
|
||||||
|
"nextStep": "Review the recommendation again before retrying."
|
||||||
|
},
|
||||||
|
"OVERLAP_STILL_PRESENT": {
|
||||||
|
"title": "Overlap was not resolved",
|
||||||
|
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
|
||||||
|
},
|
||||||
|
"EMPTY_VALUE": {
|
||||||
|
"title": "A required field is blank",
|
||||||
|
"explanation": "This field cannot be left blank.",
|
||||||
|
"nextStep": "Enter a value and try again."
|
||||||
|
},
|
||||||
|
"NO_FIELDS_PROVIDED": {
|
||||||
|
"title": "No changes provided",
|
||||||
|
"explanation": "At least one field must be filled in to continue."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD": {
|
||||||
|
"title": "Field not allowed here",
|
||||||
|
"explanation": "One of the fields provided is not permitted for this action."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD_OVERRIDE": {
|
||||||
|
"title": "Field cannot be merged",
|
||||||
|
"explanation": "One of the selected fields cannot be used in this merge."
|
||||||
|
},
|
||||||
|
"INVALID_SURVIVOR": {
|
||||||
|
"title": "Invalid selection",
|
||||||
|
"explanation": "The record you selected to keep must be one of the two records being compared."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_REFERENCE": {
|
||||||
|
"title": "Booking reference not valid here",
|
||||||
|
"explanation": "The selected booking is not one of the bookings related to this issue."
|
||||||
|
},
|
||||||
|
"INVALID_EVENT_ID": {
|
||||||
|
"title": "Invalid event reference",
|
||||||
|
"explanation": "This automation event reference is not valid."
|
||||||
|
},
|
||||||
|
"INVALID_IDEMPOTENCY_KEY": {
|
||||||
|
"title": "Request could not be repeated safely",
|
||||||
|
"explanation": "This request's tracking key is not valid.",
|
||||||
|
"nextStep": "Reload the page and try again."
|
||||||
|
},
|
||||||
|
"IDEMPOTENCY_KEY_REUSED": {
|
||||||
|
"title": "This action was already submitted",
|
||||||
|
"explanation": "An identical request was already processed with a different outcome.",
|
||||||
|
"nextStep": "Reload the page to see the current state before retrying."
|
||||||
|
},
|
||||||
|
"CORRECTED_VALUE_REQUIRED": {
|
||||||
|
"title": "A corrected value is required",
|
||||||
|
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
|
||||||
|
},
|
||||||
|
"CORRECTION_BELOW_CANONICAL": {
|
||||||
|
"title": "Correction is below the confirmed reading",
|
||||||
|
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
|
||||||
|
},
|
||||||
|
"NOT_A_DUPLICATE_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to possible-duplicate-customer issues."
|
||||||
|
},
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to missing-required-field issues."
|
||||||
|
},
|
||||||
|
"NOT_AN_ODOMETER_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to odometer-regression issues."
|
||||||
|
},
|
||||||
|
"NOT_AN_OVERLAP_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to booking-overlap issues."
|
||||||
|
},
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE": {
|
||||||
|
"title": "Wrong issue type",
|
||||||
|
"explanation": "This action only applies to vehicle-status-conflict issues."
|
||||||
|
},
|
||||||
|
"UNSUPPORTED_ENTITY": {
|
||||||
|
"title": "Not supported for this record type",
|
||||||
|
"explanation": "This action is not available for this kind of record."
|
||||||
|
},
|
||||||
|
"MANUAL_REVIEW_REQUIRED": {
|
||||||
|
"title": "Manual review required",
|
||||||
|
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
|
||||||
|
"nextStep": "Defer or reject this issue, or investigate manually."
|
||||||
|
},
|
||||||
|
"NO_CONFLICT_DETECTED": {
|
||||||
|
"title": "Nothing to apply",
|
||||||
|
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
|
||||||
|
},
|
||||||
|
"RECOMMENDATION_STALE": {
|
||||||
|
"title": "The situation has changed",
|
||||||
|
"explanation": "The underlying facts changed since this recommendation was shown.",
|
||||||
|
"nextStep": "Review the recommendation again before applying it."
|
||||||
|
},
|
||||||
|
"UNAUTHORIZED_SERVICE": {
|
||||||
|
"title": "Service authorisation failed",
|
||||||
|
"explanation": "This automated request could not be authorised."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"401": {
|
||||||
|
"title": "Session expired",
|
||||||
|
"explanation": "Your session has expired.",
|
||||||
|
"nextStep": "Please log in again."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"title": "Permission denied",
|
||||||
|
"explanation": "You don't have permission to perform this action."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "Not found",
|
||||||
|
"explanation": "This record could not be found."
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"title": "This action is no longer possible",
|
||||||
|
"explanation": "The underlying state has changed since this page was loaded.",
|
||||||
|
"nextStep": "Refresh the page and try again."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"title": "Validation failed",
|
||||||
|
"explanation": "The information provided is not valid."
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"title": "Server error",
|
||||||
|
"explanation": "Something went wrong on our side."
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Connection unavailable",
|
||||||
|
"explanation": "The connection to the server is currently unavailable.",
|
||||||
|
"nextStep": "Check your connection and try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,14 @@
|
|||||||
"noQualityIssues": "No quality issues recorded.",
|
"noQualityIssues": "No quality issues recorded.",
|
||||||
"fuel": "Fuel {{percent}}%",
|
"fuel": "Fuel {{percent}}%",
|
||||||
"damage": "Damage",
|
"damage": "Damage",
|
||||||
"technicalWarning": "Technical warning"
|
"technicalWarning": "Technical warning",
|
||||||
|
"inspectionTypes": {
|
||||||
|
"departure": "Departure inspection",
|
||||||
|
"return": "Return inspection"
|
||||||
|
},
|
||||||
|
"maintenanceCategories": {
|
||||||
|
"periodic_service": "Periodic service",
|
||||||
|
"repair": "Repair"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,28 @@
|
|||||||
"demoMode": "Demo mode",
|
"demoMode": "Demo mode",
|
||||||
"unavailable": "Unavailable"
|
"unavailable": "Unavailable"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Automation workflows",
|
||||||
|
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
|
||||||
|
"notBuilt": "Not built yet",
|
||||||
|
"noEvidence": "No evidence yet",
|
||||||
|
"columns": {
|
||||||
|
"name": "Workflow",
|
||||||
|
"status": "Status",
|
||||||
|
"lastSeen": "Last evidence",
|
||||||
|
"technicalId": "Details"
|
||||||
|
},
|
||||||
|
"names": {
|
||||||
|
"vehicleReturn": "Vehicle return orchestration",
|
||||||
|
"scheduledScan": "Scheduled data quality scan",
|
||||||
|
"ragcoreSync": "Knowledge procedure sync",
|
||||||
|
"errorHandler": "Workflow error handler"
|
||||||
|
},
|
||||||
|
"errorHandler": {
|
||||||
|
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
|
||||||
|
"summaryEmpty": "No automation failures have been registered."
|
||||||
|
}
|
||||||
|
},
|
||||||
"ledger": {
|
"ledger": {
|
||||||
"title": "Automation jobs",
|
"title": "Automation jobs",
|
||||||
"description": "Persisted automation attempts with the latest failure evidence.",
|
"description": "Persisted automation attempts with the latest failure evidence.",
|
||||||
@@ -65,6 +87,13 @@
|
|||||||
"noAction": "—",
|
"noAction": "—",
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"vehicle.returned.v1": "Vehicle return processed"
|
"vehicle.returned.v1": "Vehicle return processed"
|
||||||
|
},
|
||||||
|
"errorCodes": {
|
||||||
|
"connectionError": "The workflow service was temporarily unreachable. The return was safely saved and can be retried.",
|
||||||
|
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
|
||||||
|
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
|
||||||
|
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
|
||||||
|
"unknownError": "An unexpected error occurred while delivering this job."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"Which checks are required before checkout?"
|
"Which checks are required before checkout?"
|
||||||
],
|
],
|
||||||
"emptyTitle": "Evidence before answers",
|
"emptyTitle": "Evidence before answers",
|
||||||
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. Fleet Ops will not invent an answer when evidence is missing.",
|
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. {{productName}} will not invent an answer when evidence is missing.",
|
||||||
"retrievalFlow": {
|
"retrievalFlow": {
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
|
|||||||
@@ -27,12 +27,22 @@
|
|||||||
"resetConfirmYes": "Yes, reset",
|
"resetConfirmYes": "Yes, reset",
|
||||||
"resetCancel": "Cancel",
|
"resetCancel": "Cancel",
|
||||||
"resetFailed": "Could not reset demo data.",
|
"resetFailed": "Could not reset demo data.",
|
||||||
"searchLabel": "Search Fleet Ops",
|
"searchLabel": "Search {{productName}}",
|
||||||
"searchPlaceholder": "Search fleet, booking or section…",
|
"searchPlaceholder": "Search fleet, booking or section…",
|
||||||
"searchShortcutHint": "Ctrl K",
|
"searchShortcutHint": "Ctrl K",
|
||||||
"searchSearching": "Searching…",
|
"searchSearching": "Searching…",
|
||||||
"searchUnavailable": "Search is unavailable right now.",
|
"searchUnavailable": "Search is unavailable right now.",
|
||||||
"searchNoResults": "No matches for \"{{query}}\".",
|
"searchNoResults": "No matches for \"{{query}}\".",
|
||||||
|
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||||
|
"searchSections": {
|
||||||
|
"overview": "Operations dashboard",
|
||||||
|
"fleet": "Vehicle registry",
|
||||||
|
"bookings": "Rental bookings",
|
||||||
|
"quality": "Quality workbench",
|
||||||
|
"knowledge": "Procedure assistant",
|
||||||
|
"integrations": "Automation and integration status",
|
||||||
|
"audit": "Audit history"
|
||||||
|
},
|
||||||
"switchRole": "Switch role",
|
"switchRole": "Switch role",
|
||||||
"switchRoleTitle": "Switch demo role",
|
"switchRoleTitle": "Switch demo role",
|
||||||
"languageSwitcherLabel": "Change language"
|
"languageSwitcherLabel": "Change language"
|
||||||
|
|||||||
@@ -101,6 +101,17 @@
|
|||||||
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
|
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"evidence": {
|
||||||
|
"duplicate.exact_email": "Identical email address",
|
||||||
|
"duplicate.exact_phone": "Identical phone number",
|
||||||
|
"duplicate.same_postal_code": "Same postal code",
|
||||||
|
"duplicateSimilarName": "Strongly similar name (score {{score}})",
|
||||||
|
"missingField": "Missing field: {{field}}",
|
||||||
|
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
||||||
|
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
||||||
|
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.",
|
||||||
|
"seedPlaceholder": "Synthetic seed data with no further detail."
|
||||||
|
},
|
||||||
"duplicateCustomer": {
|
"duplicateCustomer": {
|
||||||
"heading": "Compare and merge",
|
"heading": "Compare and merge",
|
||||||
"description": "Choose the canonical customer and review each conflicting field.",
|
"description": "Choose the canonical customer and review each conflicting field.",
|
||||||
@@ -180,13 +191,48 @@
|
|||||||
"heading": "Resolve the status conflict",
|
"heading": "Resolve the status conflict",
|
||||||
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
|
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
|
||||||
"currentStatus": "Current status",
|
"currentStatus": "Current status",
|
||||||
"calculateAndApply": "Calculate and apply recommended status",
|
"reviewRecommendation": "Review recommendation",
|
||||||
"confirmTitle": "Confirm status change",
|
"loadingRecommendation": "Loading recommendation…",
|
||||||
"confirmBody": "Apply the authoritative recommended status for this vehicle?",
|
"recommendationFailed": "Could not load a recommendation.",
|
||||||
|
"recommendedStatus": "Recommended status",
|
||||||
|
"whyHeading": "Why",
|
||||||
|
"evidenceHeading": "Facts",
|
||||||
|
"consequenceHeading": "Consequence",
|
||||||
|
"consequence": {
|
||||||
|
"statusWillChange": "The vehicle status will change to {{status}}.",
|
||||||
|
"issueWillBeRechecked": "This quality issue will be re-checked.",
|
||||||
|
"changeWillBeAudited": "The change will be recorded in the audit trail.",
|
||||||
|
"bookingsNotDeleted": "The bookings themselves are not deleted."
|
||||||
|
},
|
||||||
|
"changeStatusTo": "Change status to {{status}}",
|
||||||
"applying": "Applying…",
|
"applying": "Applying…",
|
||||||
"confirmYes": "Yes, apply",
|
|
||||||
"applied": "Applied {{status}} — {{reason}}",
|
"applied": "Applied {{status}} — {{reason}}",
|
||||||
"applyFailed": "Could not apply a recommended status."
|
"applyFailed": "Could not apply a recommended status.",
|
||||||
|
"staleRecommendation": "The situation has changed since this recommendation was shown. Review it again before applying.",
|
||||||
|
"reviewAgain": "Review recommendation again",
|
||||||
|
"manualReview": {
|
||||||
|
"heading": "Manual review required",
|
||||||
|
"body": "The facts for this vehicle contradict each other. An Operations Manager needs to review this in person rather than apply an automatic status change.",
|
||||||
|
"hint": "Use 'Defer' or 'Reject' below, or investigate the vehicle and the related bookings manually."
|
||||||
|
},
|
||||||
|
"noConflict": {
|
||||||
|
"heading": "No change needed",
|
||||||
|
"body": "This vehicle's current status already matches the facts."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"activeBookings": "Active booking(s): {{refs}}",
|
||||||
|
"overlappingBookings": "Overlapping bookings: {{pairs}}",
|
||||||
|
"serviceThresholdReached": "Odometer reading ({{odometer}} km) has reached the service threshold ({{threshold}} km).",
|
||||||
|
"openOverlapIssue": "Open booking-overlap issue: {{ref}}"
|
||||||
|
},
|
||||||
|
"reasonCodes": {
|
||||||
|
"vehicle.active_rental": "This vehicle has an active rental but is not recorded as rented. The status is corrected to reflect the actual situation.",
|
||||||
|
"vehicle.service_threshold_reached": "This vehicle has reached its service threshold. It is set to maintenance so it is not deployed before servicing takes place.",
|
||||||
|
"vehicle.booking_conflict": "This vehicle has two overlapping bookings. Blocking it prevents it from being considered available again before the booking conflict is resolved.",
|
||||||
|
"vehicle.rental_ended": "The rental for this vehicle has ended and there is no active booking left. It is set back to available.",
|
||||||
|
"vehicle.manual_review_required": "The facts for this vehicle contradict each other (for example, an active booking while the vehicle is in maintenance, or a service threshold reached alongside an active rental). This requires a human review rather than an automatic status change.",
|
||||||
|
"vehicle.no_conflict": "This vehicle's current status already matches the facts. No change is needed."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
|
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
|
||||||
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
|
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
|
||||||
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
|
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
|
||||||
|
"reasonCodes": {
|
||||||
|
"returnBlockedDamageAndTechnical": "Both damage and a technical warning were reported on this return.",
|
||||||
|
"returnBlockedDamage": "Damage was reported on this return.",
|
||||||
|
"returnBlockedTechnicalWarning": "A technical warning was reported on this return.",
|
||||||
|
"returnServiceThresholdReached": "The odometer reading has reached the {{threshold_km}} km service threshold.",
|
||||||
|
"returnRoutedToCleaning": "No damage, technical warning or service threshold reached; the vehicle goes to cleaning."
|
||||||
|
},
|
||||||
"commitList": {
|
"commitList": {
|
||||||
"inspection": "Create a return inspection",
|
"inspection": "Create a return inspection",
|
||||||
"updateAtomic": "Update the booking and vehicle atomically",
|
"updateAtomic": "Update the booking and vehicle atomically",
|
||||||
@@ -66,8 +73,9 @@
|
|||||||
"continueDemo": "Continue the demo"
|
"continueDemo": "Continue the demo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
"ariaLabel": "Demo scenario",
|
||||||
"title": "Demo scenario: odometer anomaly",
|
"title": "Demo scenario: odometer anomaly",
|
||||||
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how Fleet Ops detects and handles this.",
|
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how {{productName}} detects and handles this.",
|
||||||
"preparing": "Preparing scenario…"
|
"preparing": "Preparing scenario…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||