Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09173a4740 | ||
|
|
9468cc3e21 | ||
|
|
f0d641198c | ||
|
|
77208b857a | ||
|
|
e427313bce | ||
|
|
d17af1c52a | ||
|
|
94cfb7bcbb | ||
|
|
37a362c4a0 | ||
|
|
1fbb20b1ab | ||
|
|
f7805579f7 | ||
|
|
de0bdea84f | ||
|
|
284b3c7394 | ||
|
|
2e4fb43f09 | ||
|
|
cda2c32bd0 | ||
|
|
7851e807fa | ||
|
|
a7ac5ed9d0 | ||
|
|
1e407754e6 | ||
|
|
1fdd2b3ccf | ||
|
|
ac4b1636fe | ||
|
|
e6539d17b6 | ||
|
|
6deb95524d | ||
|
|
18344bc8b7 | ||
|
|
18a765d623 |
@@ -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,253 @@ 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) — IN PROGRESS on fix/fleet-ops-final-i18n-ux
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -157,16 +159,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
|
||||||
|
|
||||||
|
|
||||||
@@ -269,6 +296,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,11 +109,11 @@ 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"],
|
||||||
@@ -122,7 +123,8 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"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,39 +235,32 @@ 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()}]
|
||||||
|
summary = (
|
||||||
|
f"Recommended status: {recommendation.recommended_status}"
|
||||||
|
if recommendation.recommended_status
|
||||||
|
else "Manual review required: active rental conflicts with a blocking condition"
|
||||||
|
)
|
||||||
_open_issue(
|
_open_issue(
|
||||||
db,
|
db,
|
||||||
scan,
|
scan,
|
||||||
@@ -253,9 +268,13 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
|||||||
entity_type="vehicle",
|
entity_type="vehicle",
|
||||||
entity_id=vehicle.id,
|
entity_id=vehicle.id,
|
||||||
severity="high",
|
severity="high",
|
||||||
summary=f"Vehicle {reason}",
|
summary=summary,
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[],
|
related_refs=[
|
||||||
|
*facts.active_booking_refs,
|
||||||
|
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
|
||||||
|
],
|
||||||
|
signals=signals,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
|
"This vehicle's state requires manual review; no automatic status change is safe.",
|
||||||
|
status_code=409,
|
||||||
)
|
)
|
||||||
has_open_high_issue = (
|
if recommendation.recommended_status is None:
|
||||||
db.scalar(
|
|
||||||
select(DataQualityIssue.id).where(
|
|
||||||
DataQualityIssue.entity_type == "vehicle",
|
|
||||||
DataQualityIssue.entity_id == vehicle.id,
|
|
||||||
DataQualityIssue.status == "open",
|
|
||||||
DataQualityIssue.severity == "high",
|
|
||||||
DataQualityIssue.id != issue.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
is not 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,8 +112,10 @@ 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(
|
||||||
@@ -124,9 +127,11 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
success = bool(body.get("ok", True))
|
success = bool(body.get("ok", True))
|
||||||
error = None if success else f"n8n reported failure: {body}"
|
error = None if success else f"n8n reported failure: {body}"
|
||||||
|
error_code = None if success else "remoteReportedFailure"
|
||||||
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 +143,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
|
||||||
|
|||||||
@@ -35,7 +35,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:
|
||||||
@@ -218,17 +222,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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ 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):
|
||||||
@@ -98,6 +99,7 @@ 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_exhausts_attempts_to_failed(monkeypatch):
|
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||||
@@ -159,6 +161,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 +211,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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,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…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||||
"actionFilterLabel": "Action",
|
"actionFilterLabel": "Action",
|
||||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||||
"managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.",
|
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
|
||||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.",
|
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
|
||||||
"loading": "Chargement de la piste d'audit…",
|
"loading": "Chargement de la piste d'audit…",
|
||||||
"unavailable": "La piste d'audit est actuellement indisponible.",
|
"unavailable": "La piste d'audit est actuellement indisponible.",
|
||||||
"empty": "Aucun événement d'audit trouvé",
|
"empty": "Aucun événement d'audit trouvé",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
||||||
"columns": {
|
"columns": {
|
||||||
"when": "Quand",
|
"when": "Quand",
|
||||||
"actor": "Acteur",
|
"actor": "Auteur",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"entity": "Entité",
|
"entity": "Entité",
|
||||||
"change": "Changement",
|
"change": "Changement",
|
||||||
@@ -40,6 +40,29 @@
|
|||||||
"was": "{{field}} : était {{value}}",
|
"was": "{{field}} : était {{value}}",
|
||||||
"changed": "{{field}} : {{before}} → {{after}}"
|
"changed": "{{field}} : {{before}} → {{after}}"
|
||||||
},
|
},
|
||||||
|
"actorTypes": {
|
||||||
|
"user": "Utilisateur",
|
||||||
|
"system": "Système",
|
||||||
|
"service": "Service"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"status": "Statut",
|
||||||
|
"operational_status": "Statut opérationnel",
|
||||||
|
"registration_number": "Plaque d'immatriculation",
|
||||||
|
"make": "Marque",
|
||||||
|
"model": "Modèle",
|
||||||
|
"location": "Emplacement",
|
||||||
|
"first_name": "Prénom",
|
||||||
|
"last_name": "Nom",
|
||||||
|
"email": "E-mail",
|
||||||
|
"phone": "Téléphone",
|
||||||
|
"postal_code": "Code postal",
|
||||||
|
"city": "Ville",
|
||||||
|
"booking_end_odometer_km": "Kilométrage final de la réservation",
|
||||||
|
"vehicle_odometer_km": "Kilométrage du véhicule",
|
||||||
|
"survivor": "Profil conservé",
|
||||||
|
"loser": "Profil fusionné"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Connecté",
|
"demo_login": "Connecté",
|
||||||
"demo_logout": "Déconnecté",
|
"demo_logout": "Déconnecté",
|
||||||
|
|||||||
@@ -3,21 +3,21 @@
|
|||||||
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
|
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
|
||||||
"headline1": "Chaque transfert.",
|
"headline1": "Chaque transfert.",
|
||||||
"headline2": "Une vue claire.",
|
"headline2": "Une vue claire.",
|
||||||
"defaultDescription": "Fleet Ops rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
|
"defaultDescription": "{{productName}} rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
|
||||||
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
|
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
|
||||||
"accessEyebrow": "Accès démo",
|
"accessEyebrow": "Accès démo",
|
||||||
"accessHeading": "Choisissez comment démarrer",
|
"accessHeading": "Choisissez comment démarrer",
|
||||||
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
||||||
"startGuidedDemo": "Démarrer la démo guidée",
|
"startGuidedDemo": "Démarrer la démo guidée",
|
||||||
"exploreAsOperationsManager": "Explorer en tant qu'Operations Manager",
|
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
||||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||||
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee",
|
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
||||||
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
||||||
"safeByDesignTitle": "Conçu pour la sécurité",
|
"safeByDesignTitle": "Conçu pour la sécurité",
|
||||||
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
||||||
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
||||||
"roleOperationsManager": "Operations manager",
|
"roleOperationsManager": "Responsable des opérations",
|
||||||
"roleRentalEmployee": "Rental employee",
|
"roleRentalEmployee": "Collaborateur de location",
|
||||||
"switchRole": "Changer de rôle",
|
"switchRole": "Changer de rôle",
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appName": "Fleet Ops",
|
|
||||||
"orgName": "Northstar Mobility",
|
"orgName": "Northstar Mobility",
|
||||||
"brandTagline": "Centre de contrôle",
|
"brandTagline": "Centre de contrôle",
|
||||||
"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/Bruxelles · Données de démonstration synthétiques"
|
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Exploitation / Aperçu en direct",
|
"eyebrow": "Exploitation / Aperçu en direct",
|
||||||
"title": "Bonjour. Voici votre flotte.",
|
"greeting": {
|
||||||
|
"morning": "Bonjour",
|
||||||
|
"afternoon": "Bonjour",
|
||||||
|
"evening": "Bonsoir",
|
||||||
|
"night": "Bon retour"
|
||||||
|
},
|
||||||
|
"greetingBody": {
|
||||||
|
"morning": "Voici l'état de votre flotte pour aujourd'hui.",
|
||||||
|
"afternoon": "Voici l'aperçu actuel de votre flotte.",
|
||||||
|
"evening": "Voici l'aperçu de votre flotte pour ce soir.",
|
||||||
|
"night": "Voici le dernier aperçu de votre flotte."
|
||||||
|
},
|
||||||
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
|
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
|
||||||
"viewFleet": "Voir la flotte",
|
"viewFleet": "Voir la flotte",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"understand-state": {
|
"understand-state": {
|
||||||
"title": "1. Comprendre l'état opérationnel",
|
"title": "1. Comprendre l'état opérationnel",
|
||||||
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
|
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
|
||||||
"whyItMatters": "Un Operations Manager commence chaque journée par cet aperçu pour décider où intervenir.",
|
"whyItMatters": "Un Responsable des opérations commence chaque journée par cet aperçu pour décider où intervenir.",
|
||||||
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
|
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
|
||||||
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
|
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
|
||||||
},
|
},
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
|
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
|
||||||
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
|
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
|
||||||
"startAction": "Lisez la page « À propos de cette démo ».",
|
"startAction": "Lisez la page « À propos de cette démo ».",
|
||||||
"expectedOutcome": "Vous pouvez expliquer vous-même ce que Fleet Ops est et n'est pas, sans explication verbale nécessaire."
|
"expectedOutcome": "Vous pouvez expliquer vous-même ce que {{productName}} est et n'est pas, sans explication verbale nécessaire."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
"startScenario": "Démarrer le scénario",
|
"startScenario": "Démarrer le scénario",
|
||||||
"roleOr": "{{a}} ou {{b}}",
|
"roleOr": "{{a}} ou {{b}}",
|
||||||
"roles": {
|
"roles": {
|
||||||
"operations_manager": "Operations Manager",
|
"operations_manager": "Responsable des opérations",
|
||||||
"rental_employee": "Rental Employee"
|
"rental_employee": "Collaborateur de location"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"return-anomaly": {
|
"return-anomaly": {
|
||||||
@@ -146,16 +146,16 @@
|
|||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"eyebrow": "À propos de cette démo",
|
"eyebrow": "À propos de cette démo",
|
||||||
"title": "Ce que Fleet Ops est et n'est pas",
|
"title": "Ce que {{productName}} est et n'est pas",
|
||||||
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
||||||
"loading": "Chargement des informations de démo…",
|
"loading": "Chargement des informations de démo…",
|
||||||
"ctaTitle": "Vous préférez vous lancer directement ?",
|
"ctaTitle": "Vous préférez vous lancer directement ?",
|
||||||
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
|
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
|
||||||
"ctaButton": "Démarrer la démo guidée",
|
"ctaButton": "Démarrer la démo guidée",
|
||||||
"problemTitle": "Le problème fictif",
|
"problemTitle": "Le problème fictif",
|
||||||
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. Fleet Ops montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
||||||
"scopeTitle": "Pour qui et avec quelle portée",
|
"scopeTitle": "Pour qui et avec quelle portée",
|
||||||
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment Fleet Ops traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Responsables des opérations et Collaborateurs de location, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
||||||
"realTitle": "Ce qui fonctionne réellement",
|
"realTitle": "Ce qui fonctionne réellement",
|
||||||
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
|
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
|
||||||
"syntheticTitle": "Ce qui est synthétique",
|
"syntheticTitle": "Ce qui est synthétique",
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
|
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
|
||||||
"resetTitle": "Restaurer l'environnement de démo",
|
"resetTitle": "Restaurer l'environnement de démo",
|
||||||
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
||||||
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Operations Manager peut réinitialiser l'environnement de démo via la barre latérale.",
|
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
|
||||||
"limitationsTitle": "Limitations",
|
"limitationsTitle": "Limitations",
|
||||||
"limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.",
|
"limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.",
|
||||||
"unknown": "inconnu"
|
"unknown": "inconnu"
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Une erreur est survenue. Veuillez réessayer.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "Nous n'avons pas pu charger cet espace de travail.",
|
"title": "Une erreur s'est produite",
|
||||||
"unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
|
"explanation": "Cette action n'a pas pu être terminée. Veuillez réessayer."
|
||||||
"forbidden": "Vous n'avez pas accès à cette section.",
|
},
|
||||||
"notFound": "Cette fiche est introuvable.",
|
"codes": {
|
||||||
"networkUnavailable": "La connexion au serveur est actuellement indisponible."
|
"VEHICLE_NOT_FOUND": {
|
||||||
|
"title": "Véhicule introuvable",
|
||||||
|
"explanation": "Ce véhicule est introuvable. Il a peut-être été supprimé, ou la référence est incorrecte."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_FOUND": {
|
||||||
|
"title": "Réservation introuvable",
|
||||||
|
"explanation": "Cette réservation est introuvable. Elle a peut-être été supprimée, ou la référence est incorrecte."
|
||||||
|
},
|
||||||
|
"CUSTOMER_NOT_FOUND": {
|
||||||
|
"title": "Client introuvable",
|
||||||
|
"explanation": "Cette fiche client est introuvable."
|
||||||
|
},
|
||||||
|
"ENTITY_NOT_FOUND": {
|
||||||
|
"title": "Fiche introuvable",
|
||||||
|
"explanation": "La fiche sous-jacente à cette action est introuvable."
|
||||||
|
},
|
||||||
|
"EVENT_NOT_FOUND": {
|
||||||
|
"title": "Tâche d'automatisation introuvable",
|
||||||
|
"explanation": "Cet événement d'automatisation est introuvable."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_FOUND": {
|
||||||
|
"title": "Problème de qualité des données introuvable",
|
||||||
|
"explanation": "Ce problème de qualité des données est introuvable."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_OPEN": {
|
||||||
|
"title": "Le problème n'est plus ouvert",
|
||||||
|
"explanation": "Ce problème a déjà été résolu, reporté ou rejeté.",
|
||||||
|
"nextStep": "Actualisez la page pour voir son état actuel."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_ACTIVE": {
|
||||||
|
"title": "La réservation n'est pas active",
|
||||||
|
"explanation": "Seule une réservation réservée ou active peut être utilisée pour cette action."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_STATE": {
|
||||||
|
"title": "La réservation est dans le mauvais état",
|
||||||
|
"explanation": "L'état actuel de cette réservation ne permet pas cette action."
|
||||||
|
},
|
||||||
|
"NOT_RETRYABLE": {
|
||||||
|
"title": "Cette tâche ne peut pas être relancée",
|
||||||
|
"explanation": "Seule une livraison échouée peut être relancée."
|
||||||
|
},
|
||||||
|
"CONFLICT_STILL_PRESENT": {
|
||||||
|
"title": "Le conflit n'a pas été résolu",
|
||||||
|
"explanation": "L'application de ce changement n'a pas résolu le conflit sous-jacent.",
|
||||||
|
"nextStep": "Consultez à nouveau la recommandation avant de réessayer."
|
||||||
|
},
|
||||||
|
"OVERLAP_STILL_PRESENT": {
|
||||||
|
"title": "Le chevauchement n'a pas été résolu",
|
||||||
|
"explanation": "Le blocage de cette réservation n'a pas supprimé le chevauchement ; un autre engagement subsiste."
|
||||||
|
},
|
||||||
|
"EMPTY_VALUE": {
|
||||||
|
"title": "Un champ requis est vide",
|
||||||
|
"explanation": "Ce champ ne peut pas rester vide.",
|
||||||
|
"nextStep": "Saisissez une valeur et réessayez."
|
||||||
|
},
|
||||||
|
"NO_FIELDS_PROVIDED": {
|
||||||
|
"title": "Aucune modification fournie",
|
||||||
|
"explanation": "Au moins un champ doit être complété pour continuer."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD": {
|
||||||
|
"title": "Champ non autorisé ici",
|
||||||
|
"explanation": "L'un des champs fournis n'est pas autorisé pour cette action."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD_OVERRIDE": {
|
||||||
|
"title": "Ce champ ne peut pas être fusionné",
|
||||||
|
"explanation": "L'un des champs sélectionnés ne peut pas être utilisé dans cette fusion."
|
||||||
|
},
|
||||||
|
"INVALID_SURVIVOR": {
|
||||||
|
"title": "Sélection non valide",
|
||||||
|
"explanation": "La fiche que vous souhaitez conserver doit être l'une des deux fiches comparées."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_REFERENCE": {
|
||||||
|
"title": "Référence de réservation non valide ici",
|
||||||
|
"explanation": "La réservation sélectionnée ne fait pas partie des réservations liées à ce problème."
|
||||||
|
},
|
||||||
|
"INVALID_EVENT_ID": {
|
||||||
|
"title": "Référence d'événement non valide",
|
||||||
|
"explanation": "Cette référence d'événement d'automatisation n'est pas valide."
|
||||||
|
},
|
||||||
|
"INVALID_IDEMPOTENCY_KEY": {
|
||||||
|
"title": "La demande n'a pas pu être répétée en toute sécurité",
|
||||||
|
"explanation": "La clé de suivi de cette demande n'est pas valide.",
|
||||||
|
"nextStep": "Rechargez la page et réessayez."
|
||||||
|
},
|
||||||
|
"IDEMPOTENCY_KEY_REUSED": {
|
||||||
|
"title": "Cette action a déjà été soumise",
|
||||||
|
"explanation": "Une demande identique a déjà été traitée, avec un résultat différent.",
|
||||||
|
"nextStep": "Rechargez la page pour voir l'état actuel avant de réessayer."
|
||||||
|
},
|
||||||
|
"CORRECTED_VALUE_REQUIRED": {
|
||||||
|
"title": "Une valeur corrigée est requise",
|
||||||
|
"explanation": "Le choix « corriger le relevé » nécessite de saisir la valeur corrigée."
|
||||||
|
},
|
||||||
|
"CORRECTION_BELOW_CANONICAL": {
|
||||||
|
"title": "La correction est inférieure au relevé confirmé",
|
||||||
|
"explanation": "Un kilométrage corrigé ne peut jamais être inférieur au dernier relevé confirmé."
|
||||||
|
},
|
||||||
|
"NOT_A_DUPLICATE_ISSUE": {
|
||||||
|
"title": "Type de problème incorrect",
|
||||||
|
"explanation": "Cette action s'applique uniquement aux problèmes de client potentiellement en double."
|
||||||
|
},
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE": {
|
||||||
|
"title": "Type de problème incorrect",
|
||||||
|
"explanation": "Cette action s'applique uniquement aux problèmes de champ obligatoire manquant."
|
||||||
|
},
|
||||||
|
"NOT_AN_ODOMETER_ISSUE": {
|
||||||
|
"title": "Type de problème incorrect",
|
||||||
|
"explanation": "Cette action s'applique uniquement aux problèmes d'anomalie de kilométrage."
|
||||||
|
},
|
||||||
|
"NOT_AN_OVERLAP_ISSUE": {
|
||||||
|
"title": "Type de problème incorrect",
|
||||||
|
"explanation": "Cette action s'applique uniquement aux problèmes de chevauchement de réservations."
|
||||||
|
},
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE": {
|
||||||
|
"title": "Type de problème incorrect",
|
||||||
|
"explanation": "Cette action s'applique uniquement aux problèmes de conflit de statut du véhicule."
|
||||||
|
},
|
||||||
|
"UNSUPPORTED_ENTITY": {
|
||||||
|
"title": "Non pris en charge pour ce type de fiche",
|
||||||
|
"explanation": "Cette action n'est pas disponible pour ce type de fiche."
|
||||||
|
},
|
||||||
|
"MANUAL_REVIEW_REQUIRED": {
|
||||||
|
"title": "Évaluation manuelle requise",
|
||||||
|
"explanation": "Les faits concernant ce véhicule se contredisent, ce qui rend tout changement automatique non sûr.",
|
||||||
|
"nextStep": "Reportez ou rejetez ce problème, ou examinez-le manuellement."
|
||||||
|
},
|
||||||
|
"NO_CONFLICT_DETECTED": {
|
||||||
|
"title": "Rien à appliquer",
|
||||||
|
"explanation": "Le statut actuel correspond déjà aux faits, il n'y a donc plus rien à appliquer."
|
||||||
|
},
|
||||||
|
"RECOMMENDATION_STALE": {
|
||||||
|
"title": "La situation a changé entre-temps",
|
||||||
|
"explanation": "Les faits sous-jacents ont changé depuis l'affichage de cette recommandation.",
|
||||||
|
"nextStep": "Consultez à nouveau la recommandation avant de l'appliquer."
|
||||||
|
},
|
||||||
|
"UNAUTHORIZED_SERVICE": {
|
||||||
|
"title": "Échec de l'autorisation du service",
|
||||||
|
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"401": {
|
||||||
|
"title": "Session expirée",
|
||||||
|
"explanation": "Votre session a expiré.",
|
||||||
|
"nextStep": "Veuillez vous reconnecter."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"title": "Accès refusé",
|
||||||
|
"explanation": "Vous n'avez pas la permission d'effectuer cette action."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "Introuvable",
|
||||||
|
"explanation": "Cette fiche est introuvable."
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"title": "Cette action n'est plus possible",
|
||||||
|
"explanation": "L'état sous-jacent a changé depuis le chargement de cette page.",
|
||||||
|
"nextStep": "Actualisez la page et réessayez."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"title": "Échec de la validation",
|
||||||
|
"explanation": "Les informations fournies ne sont pas valides."
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"title": "Erreur serveur",
|
||||||
|
"explanation": "Un problème est survenu de notre côté."
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Connexion indisponible",
|
||||||
|
"explanation": "La connexion au serveur est actuellement indisponible.",
|
||||||
|
"nextStep": "Vérifiez votre connexion et réessayez."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,14 @@
|
|||||||
"noQualityIssues": "Aucun problème de qualité enregistré.",
|
"noQualityIssues": "Aucun problème de qualité enregistré.",
|
||||||
"fuel": "Carburant {{percent}}%",
|
"fuel": "Carburant {{percent}}%",
|
||||||
"damage": "Dommage",
|
"damage": "Dommage",
|
||||||
"technicalWarning": "Avertissement technique"
|
"technicalWarning": "Avertissement technique",
|
||||||
|
"inspectionTypes": {
|
||||||
|
"departure": "Inspection de départ",
|
||||||
|
"return": "Inspection de retour"
|
||||||
|
},
|
||||||
|
"maintenanceCategories": {
|
||||||
|
"periodic_service": "Entretien périodique",
|
||||||
|
"repair": "Réparation"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"statusSucceeded": "Réussi",
|
"statusSucceeded": "Réussi",
|
||||||
"statusFailed": "Échoué",
|
"statusFailed": "Échoué",
|
||||||
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
|
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
|
||||||
"managerOnly": "L'automatisation est visible uniquement pour les Operations Managers.",
|
"managerOnly": "L'automatisation est visible uniquement pour les Responsables des opérations.",
|
||||||
"loading": "Chargement des tâches d'automatisation…",
|
"loading": "Chargement des tâches d'automatisation…",
|
||||||
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
|
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
|
||||||
"count": "{{count}} événements de workflow",
|
"count": "{{count}} événements de workflow",
|
||||||
@@ -65,6 +65,13 @@
|
|||||||
"noAction": "—",
|
"noAction": "—",
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"vehicle.returned.v1": "Retour de véhicule traité"
|
"vehicle.returned.v1": "Retour de véhicule traité"
|
||||||
|
},
|
||||||
|
"errorCodes": {
|
||||||
|
"connectionError": "Le service d'automatisation était temporairement injoignable. Le retour a été enregistré en toute sécurité et peut être soumis à nouveau.",
|
||||||
|
"malformedPayload": "La tâche contenait des données incomplètes et n'a pas pu être livrée. Les données sous-jacentes restent enregistrées en toute sécurité.",
|
||||||
|
"remoteReportedFailure": "Le service d'automatisation a refusé la livraison. La tâche peut être soumise à nouveau.",
|
||||||
|
"staleLeaseRecovered": "Cette tâche a été récupérée après qu'une tentative de livraison précédente s'est arrêtée sans résultat.",
|
||||||
|
"unknownError": "Une erreur inattendue s'est produite lors de la livraison de cette tâche."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"Quels contrôles sont requis avant le départ ?"
|
"Quels contrôles sont requis avant le départ ?"
|
||||||
],
|
],
|
||||||
"emptyTitle": "Des preuves avant les réponses",
|
"emptyTitle": "Des preuves avant les réponses",
|
||||||
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. Fleet Ops n'invente jamais de réponse en l'absence de preuves.",
|
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. {{productName}} n'invente jamais de réponse en l'absence de preuves.",
|
||||||
"retrievalFlow": {
|
"retrievalFlow": {
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
|
|||||||
@@ -27,12 +27,22 @@
|
|||||||
"resetConfirmYes": "Oui, réinitialiser",
|
"resetConfirmYes": "Oui, réinitialiser",
|
||||||
"resetCancel": "Annuler",
|
"resetCancel": "Annuler",
|
||||||
"resetFailed": "Impossible de réinitialiser les données de démo.",
|
"resetFailed": "Impossible de réinitialiser les données de démo.",
|
||||||
"searchLabel": "Rechercher dans Fleet Ops",
|
"searchLabel": "Rechercher dans {{productName}}",
|
||||||
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
|
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
|
||||||
"searchShortcutHint": "Ctrl K",
|
"searchShortcutHint": "Ctrl K",
|
||||||
"searchSearching": "Recherche…",
|
"searchSearching": "Recherche…",
|
||||||
"searchUnavailable": "La recherche est momentanément indisponible.",
|
"searchUnavailable": "La recherche est momentanément indisponible.",
|
||||||
"searchNoResults": "Aucun résultat pour « {{query}} ».",
|
"searchNoResults": "Aucun résultat pour « {{query}} ».",
|
||||||
|
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||||
|
"searchSections": {
|
||||||
|
"overview": "Tableau de bord opérationnel",
|
||||||
|
"fleet": "Registre de la flotte",
|
||||||
|
"bookings": "Réservations de location",
|
||||||
|
"quality": "Atelier qualité",
|
||||||
|
"knowledge": "Assistant de procédures",
|
||||||
|
"integrations": "Statut d'automatisation et d'intégration",
|
||||||
|
"audit": "Historique d'audit"
|
||||||
|
},
|
||||||
"switchRole": "Changer de rôle",
|
"switchRole": "Changer de rôle",
|
||||||
"switchRoleTitle": "Changer de rôle de démo",
|
"switchRoleTitle": "Changer de rôle de démo",
|
||||||
"languageSwitcherLabel": "Changer de langue"
|
"languageSwitcherLabel": "Changer de langue"
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
|
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
|
||||||
"notFound": "Ce problème est introuvable.",
|
"notFound": "Ce problème est introuvable.",
|
||||||
"loading": "Chargement des preuves du problème…",
|
"loading": "Chargement des preuves du problème…",
|
||||||
"managerOnly": "L'atelier qualité est visible uniquement pour les Operations Managers.",
|
"managerOnly": "L'atelier qualité est visible uniquement pour les Responsables des opérations.",
|
||||||
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Operations Managers.",
|
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.",
|
||||||
"summary": {
|
"summary": {
|
||||||
"rule": "Règle",
|
"rule": "Règle",
|
||||||
"entity": "Entité",
|
"entity": "Entité",
|
||||||
@@ -101,6 +101,17 @@
|
|||||||
"whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte."
|
"whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"evidence": {
|
||||||
|
"duplicate.exact_email": "Adresse e-mail identique",
|
||||||
|
"duplicate.exact_phone": "Numéro de téléphone identique",
|
||||||
|
"duplicate.same_postal_code": "Même code postal",
|
||||||
|
"duplicateSimilarName": "Nom fortement similaire (score {{score}})",
|
||||||
|
"missingField": "Champ manquant : {{field}}",
|
||||||
|
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
|
||||||
|
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
|
||||||
|
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante.",
|
||||||
|
"seedPlaceholder": "Donnée de démonstration synthétique sans autre détail."
|
||||||
|
},
|
||||||
"duplicateCustomer": {
|
"duplicateCustomer": {
|
||||||
"heading": "Comparer et fusionner",
|
"heading": "Comparer et fusionner",
|
||||||
"description": "Choisissez le client de référence et examinez chaque champ divergent.",
|
"description": "Choisissez le client de référence et examinez chaque champ divergent.",
|
||||||
@@ -180,13 +191,48 @@
|
|||||||
"heading": "Résoudre le conflit de statut",
|
"heading": "Résoudre le conflit de statut",
|
||||||
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
|
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
|
||||||
"currentStatus": "Statut actuel",
|
"currentStatus": "Statut actuel",
|
||||||
"calculateAndApply": "Calculer et appliquer le statut recommandé",
|
"reviewRecommendation": "Voir la recommandation",
|
||||||
"confirmTitle": "Confirmer le changement de statut",
|
"loadingRecommendation": "Chargement de la recommandation…",
|
||||||
"confirmBody": "Appliquer le statut recommandé faisant autorité pour ce véhicule ?",
|
"recommendationFailed": "Impossible de charger une recommandation.",
|
||||||
|
"recommendedStatus": "Statut recommandé",
|
||||||
|
"whyHeading": "Pourquoi",
|
||||||
|
"evidenceHeading": "Faits",
|
||||||
|
"consequenceHeading": "Conséquence",
|
||||||
|
"consequence": {
|
||||||
|
"statusWillChange": "Le statut du véhicule sera changé en {{status}}.",
|
||||||
|
"issueWillBeRechecked": "Ce problème de qualité sera à nouveau contrôlé.",
|
||||||
|
"changeWillBeAudited": "Le changement sera enregistré dans la piste d'audit.",
|
||||||
|
"bookingsNotDeleted": "Les réservations elles-mêmes ne sont pas supprimées."
|
||||||
|
},
|
||||||
|
"changeStatusTo": "Changer le statut vers {{status}}",
|
||||||
"applying": "Application en cours…",
|
"applying": "Application en cours…",
|
||||||
"confirmYes": "Oui, appliquer",
|
|
||||||
"applied": "Appliqué {{status}} — {{reason}}",
|
"applied": "Appliqué {{status}} — {{reason}}",
|
||||||
"applyFailed": "Impossible d'appliquer un statut recommandé."
|
"applyFailed": "Impossible d'appliquer un statut recommandé.",
|
||||||
|
"staleRecommendation": "La situation a changé depuis l'affichage de cette recommandation. Consultez-la à nouveau avant de l'appliquer.",
|
||||||
|
"reviewAgain": "Revoir la recommandation",
|
||||||
|
"manualReview": {
|
||||||
|
"heading": "Évaluation manuelle requise",
|
||||||
|
"body": "Les faits concernant ce véhicule se contredisent. Un Responsable des opérations doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
|
||||||
|
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
|
||||||
|
},
|
||||||
|
"noConflict": {
|
||||||
|
"heading": "Aucun changement nécessaire",
|
||||||
|
"body": "Le statut actuel de ce véhicule correspond déjà aux faits."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"activeBookings": "Réservation(s) active(s) : {{refs}}",
|
||||||
|
"overlappingBookings": "Réservations qui se chevauchent : {{pairs}}",
|
||||||
|
"serviceThresholdReached": "Le kilométrage ({{odometer}} km) a atteint le seuil d'entretien ({{threshold}} km).",
|
||||||
|
"openOverlapIssue": "Problème de chevauchement de réservation ouvert : {{ref}}"
|
||||||
|
},
|
||||||
|
"reasonCodes": {
|
||||||
|
"vehicle.active_rental": "Ce véhicule a une location active mais n'est pas enregistré comme loué. Le statut est corrigé pour refléter la situation réelle.",
|
||||||
|
"vehicle.service_threshold_reached": "Ce véhicule a atteint son seuil d'entretien. Il est placé en entretien afin qu'il ne soit pas utilisé avant que la révision ait eu lieu.",
|
||||||
|
"vehicle.booking_conflict": "Ce véhicule a deux réservations qui se chevauchent. Le bloquer évite qu'il soit à nouveau considéré comme disponible avant que le conflit de réservation ne soit résolu.",
|
||||||
|
"vehicle.rental_ended": "La location de ce véhicule est terminée et il n'y a plus de réservation active. Il est remis en disponible.",
|
||||||
|
"vehicle.manual_review_required": "Les faits concernant ce véhicule se contredisent (par exemple, une réservation active alors que le véhicule est en entretien, ou un seuil d'entretien atteint en même temps qu'une location active). Cela nécessite une évaluation humaine plutôt qu'un changement de statut automatique.",
|
||||||
|
"vehicle.no_conflict": "Le statut actuel de ce véhicule correspond déjà aux faits. Aucun changement n'est nécessaire."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
|
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
|
||||||
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
|
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
|
||||||
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
|
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
|
||||||
|
"reasonCodes": {
|
||||||
|
"returnBlockedDamageAndTechnical": "Des dégâts et une alerte technique ont tous deux été signalés lors de ce retour.",
|
||||||
|
"returnBlockedDamage": "Des dégâts ont été signalés lors de ce retour.",
|
||||||
|
"returnBlockedTechnicalWarning": "Une alerte technique a été signalée lors de ce retour.",
|
||||||
|
"returnServiceThresholdReached": "Le kilométrage a atteint le seuil d'entretien de {{threshold_km}} km.",
|
||||||
|
"returnRoutedToCleaning": "Aucun dégât, alerte technique ou seuil d'entretien atteint ; le véhicule part en nettoyage."
|
||||||
|
},
|
||||||
"commitList": {
|
"commitList": {
|
||||||
"inspection": "Créer une inspection de retour",
|
"inspection": "Créer une inspection de retour",
|
||||||
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
|
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
|
||||||
@@ -66,8 +73,9 @@
|
|||||||
"continueDemo": "Poursuivre la démo"
|
"continueDemo": "Poursuivre la démo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
"ariaLabel": "Scénario de démo",
|
||||||
"title": "Scénario de démo : anomalie de kilométrage",
|
"title": "Scénario de démo : anomalie de kilométrage",
|
||||||
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment Fleet Ops détecte et traite cela.",
|
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment {{productName}} détecte et traite cela.",
|
||||||
"preparing": "Préparation du scénario…"
|
"preparing": "Préparation du scénario…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
|
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
|
||||||
"title": "Audit trail",
|
"title": "Auditgeschiedenis",
|
||||||
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
||||||
"actionFilterLabel": "Actie",
|
"actionFilterLabel": "Actie",
|
||||||
"actionFilterPlaceholder": "bv. demo-login",
|
"actionFilterPlaceholder": "bv. demo-login",
|
||||||
"managerOnly": "De audit trail is enkel zichtbaar voor Operations Managers.",
|
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||||
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operations Managers.",
|
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||||
"loading": "Audit trail laden…",
|
"loading": "Auditgeschiedenis laden…",
|
||||||
"unavailable": "Audit trail is momenteel niet beschikbaar.",
|
"unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.",
|
||||||
"empty": "Geen auditgebeurtenissen gevonden",
|
"empty": "Geen auditgebeurtenissen gevonden",
|
||||||
"emptyDetail": "Pas het actiefilter aan.",
|
"emptyDetail": "Pas het actiefilter aan.",
|
||||||
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
|
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
|
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
|
||||||
"columns": {
|
"columns": {
|
||||||
"when": "Wanneer",
|
"when": "Wanneer",
|
||||||
"actor": "Actor",
|
"actor": "Uitvoerder",
|
||||||
"action": "Actie",
|
"action": "Actie",
|
||||||
"entity": "Entiteit",
|
"entity": "Entiteit",
|
||||||
"change": "Wijziging",
|
"change": "Wijziging",
|
||||||
@@ -40,6 +40,29 @@
|
|||||||
"was": "{{field}}: was {{value}}",
|
"was": "{{field}}: was {{value}}",
|
||||||
"changed": "{{field}}: {{before}} → {{after}}"
|
"changed": "{{field}}: {{before}} → {{after}}"
|
||||||
},
|
},
|
||||||
|
"actorTypes": {
|
||||||
|
"user": "Gebruiker",
|
||||||
|
"system": "Systeem",
|
||||||
|
"service": "Dienst"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"status": "Status",
|
||||||
|
"operational_status": "Operationele status",
|
||||||
|
"registration_number": "Kenteken",
|
||||||
|
"make": "Merk",
|
||||||
|
"model": "Model",
|
||||||
|
"location": "Locatie",
|
||||||
|
"first_name": "Voornaam",
|
||||||
|
"last_name": "Achternaam",
|
||||||
|
"email": "E-mail",
|
||||||
|
"phone": "Telefoon",
|
||||||
|
"postal_code": "Postcode",
|
||||||
|
"city": "Stad",
|
||||||
|
"booking_end_odometer_km": "Eindkilometerstand boeking",
|
||||||
|
"vehicle_odometer_km": "Kilometerstand voertuig",
|
||||||
|
"survivor": "Behouden profiel",
|
||||||
|
"loser": "Samengevoegd profiel"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Ingelogd",
|
"demo_login": "Ingelogd",
|
||||||
"demo_logout": "Uitgelogd",
|
"demo_logout": "Uitgelogd",
|
||||||
|
|||||||
@@ -3,21 +3,21 @@
|
|||||||
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
|
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
|
||||||
"headline1": "Elke overdracht.",
|
"headline1": "Elke overdracht.",
|
||||||
"headline2": "Eén helder overzicht.",
|
"headline2": "Eén helder overzicht.",
|
||||||
"defaultDescription": "Fleet Ops brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
|
"defaultDescription": "{{productName}} brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
|
||||||
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
|
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
|
||||||
"accessEyebrow": "Demo-toegang",
|
"accessEyebrow": "Demo-toegang",
|
||||||
"accessHeading": "Kies hoe je wil starten",
|
"accessHeading": "Kies hoe je wil starten",
|
||||||
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
||||||
"startGuidedDemo": "Start begeleide demo",
|
"startGuidedDemo": "Start begeleide demo",
|
||||||
"exploreAsOperationsManager": "Verken als Operations Manager",
|
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
||||||
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
||||||
"exploreAsRentalEmployee": "Verken als Rental Employee",
|
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
||||||
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
|
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
|
||||||
"safeByDesignTitle": "Veilig ontworpen",
|
"safeByDesignTitle": "Veilig ontworpen",
|
||||||
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
|
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
|
||||||
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
|
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
|
||||||
"roleOperationsManager": "Operationsmanager",
|
"roleOperationsManager": "Operationsmanager",
|
||||||
"roleRentalEmployee": "Rental employee",
|
"roleRentalEmployee": "Verhuurmedewerker",
|
||||||
"switchRole": "Wissel van rol",
|
"switchRole": "Wissel van rol",
|
||||||
"logout": "Uitloggen"
|
"logout": "Uitloggen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appName": "Fleet Ops",
|
|
||||||
"orgName": "Northstar Mobility",
|
"orgName": "Northstar Mobility",
|
||||||
"brandTagline": "Bedieningscentrum",
|
"brandTagline": "Bedieningscentrum",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
"fr-BE": "Français"
|
"fr-BE": "Français"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"productLine": "Fleet Ops Demo",
|
"productLine": "{{productName}} Demo",
|
||||||
"locale": "Europa/Brussel · Synthetische demogegevens"
|
"locale": "Europa/Brussel · Synthetische demogegevens"
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Uitvoeren / Live overzicht",
|
"eyebrow": "Uitvoeren / Live overzicht",
|
||||||
"title": "Goedemorgen. Hier is je wagenpark.",
|
"greeting": {
|
||||||
|
"morning": "Goedemorgen",
|
||||||
|
"afternoon": "Goedemiddag",
|
||||||
|
"evening": "Goedenavond",
|
||||||
|
"night": "Welkom terug"
|
||||||
|
},
|
||||||
|
"greetingBody": {
|
||||||
|
"morning": "Hier is de status van je wagenpark voor vandaag.",
|
||||||
|
"afternoon": "Hier is het actuele overzicht van je wagenpark.",
|
||||||
|
"evening": "Hier is het overzicht van je wagenpark voor vanavond.",
|
||||||
|
"night": "Hier is het laatste overzicht van je wagenpark."
|
||||||
|
},
|
||||||
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
|
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
|
||||||
"viewFleet": "Wagenpark bekijken",
|
"viewFleet": "Wagenpark bekijken",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"understand-state": {
|
"understand-state": {
|
||||||
"title": "1. Begrijp de operationele status",
|
"title": "1. Begrijp de operationele status",
|
||||||
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
|
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
|
||||||
"whyItMatters": "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
|
"whyItMatters": "Een Operationsmanager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
|
||||||
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
|
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
|
||||||
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
|
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
|
||||||
},
|
},
|
||||||
@@ -72,10 +72,10 @@
|
|||||||
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
|
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
|
||||||
},
|
},
|
||||||
"check-automation-audit": {
|
"check-automation-audit": {
|
||||||
"title": "7. Controleer automatisering en audit trail",
|
"title": "7. Controleer automatisering en auditgeschiedenis",
|
||||||
"whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
|
"whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
|
||||||
"whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
|
"whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
|
||||||
"startAction": "Open Integraties om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
|
"startAction": "Open Integraties om de afleverstatus te zien, en Auditgeschiedenis voor het volledige spoor.",
|
||||||
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
|
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
|
||||||
},
|
},
|
||||||
"review-real-vs-simulated": {
|
"review-real-vs-simulated": {
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"whatYouWillSee": "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
|
"whatYouWillSee": "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
|
||||||
"whyItMatters": "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
|
"whyItMatters": "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
|
||||||
"startAction": "Lees de pagina 'Over deze demo'.",
|
"startAction": "Lees de pagina 'Over deze demo'.",
|
||||||
"expectedOutcome": "Je kan zelf uitleggen wat Fleet Ops wel en niet is, zonder mondelinge toelichting."
|
"expectedOutcome": "Je kan zelf uitleggen wat {{productName}} wel en niet is, zonder mondelinge toelichting."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,22 +99,22 @@
|
|||||||
"role": "Rol",
|
"role": "Rol",
|
||||||
"demonstrates": "Toont aan:",
|
"demonstrates": "Toont aan:",
|
||||||
"requiresRole": "Vereist rol: {{roles}}.",
|
"requiresRole": "Vereist rol: {{roles}}.",
|
||||||
"startScenario": "Start scenario",
|
"startScenario": "Scenario starten",
|
||||||
"roleOr": "{{a}} of {{b}}",
|
"roleOr": "{{a}} of {{b}}",
|
||||||
"roles": {
|
"roles": {
|
||||||
"operations_manager": "Operations Manager",
|
"operations_manager": "Operationsmanager",
|
||||||
"rental_employee": "Rental Employee"
|
"rental_employee": "Verhuurmedewerker"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"return-anomaly": {
|
"return-anomaly": {
|
||||||
"title": "Retour met afwijkende kilometerstand",
|
"title": "Retour met afwijkende kilometerstand",
|
||||||
"problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.",
|
"problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.",
|
||||||
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de audit trail die daaruit ontstaat."
|
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de auditgeschiedenis die daaruit ontstaat."
|
||||||
},
|
},
|
||||||
"duplicate-customer": {
|
"duplicate-customer": {
|
||||||
"title": "Mogelijke dubbele klant samenvoegen",
|
"title": "Mogelijke dubbele klant samenvoegen",
|
||||||
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
|
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
|
||||||
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail."
|
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en auditgeschiedenis."
|
||||||
},
|
},
|
||||||
"booking-overlap": {
|
"booking-overlap": {
|
||||||
"title": "Overlappende boekingen herstellen",
|
"title": "Overlappende boekingen herstellen",
|
||||||
@@ -146,18 +146,18 @@
|
|||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"eyebrow": "Over deze demo",
|
"eyebrow": "Over deze demo",
|
||||||
"title": "Wat Fleet Ops wel en niet is",
|
"title": "Wat {{productName}} wel en niet is",
|
||||||
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
|
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
|
||||||
"loading": "Demo-informatie laden…",
|
"loading": "Demo-informatie laden…",
|
||||||
"ctaTitle": "Liever meteen aan de slag?",
|
"ctaTitle": "Liever meteen aan de slag?",
|
||||||
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
|
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
|
||||||
"ctaButton": "Start begeleide demo",
|
"ctaButton": "Start begeleide demo",
|
||||||
"problemTitle": "Het fictieve probleem",
|
"problemTitle": "Het fictieve probleem",
|
||||||
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. Fleet Ops toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
||||||
"scopeTitle": "Voor wie en met welke scope",
|
"scopeTitle": "Voor wie en met welke scope",
|
||||||
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe Fleet Ops operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
|
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operationsmanagers en Verhuurmedewerkers, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
|
||||||
"realTitle": "Wat écht werkt",
|
"realTitle": "Wat écht werkt",
|
||||||
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
|
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige auditgeschiedenis, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
|
||||||
"syntheticTitle": "Wat synthetisch is",
|
"syntheticTitle": "Wat synthetisch is",
|
||||||
"syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.",
|
"syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.",
|
||||||
"architectureTitle": "Architectuur in het kort",
|
"architectureTitle": "Architectuur in het kort",
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
|
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
|
||||||
"resetTitle": "Demo-omgeving herstellen",
|
"resetTitle": "Demo-omgeving herstellen",
|
||||||
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
|
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
|
||||||
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.",
|
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
|
||||||
"limitationsTitle": "Beperkingen",
|
"limitationsTitle": "Beperkingen",
|
||||||
"limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.",
|
"limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.",
|
||||||
"unknown": "onbekend"
|
"unknown": "onbekend"
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Er is een fout opgetreden. Probeer opnieuw.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "We konden deze werkruimte niet laden.",
|
"title": "Er is iets misgegaan",
|
||||||
"unauthorized": "Je sessie is verlopen. Log opnieuw in.",
|
"explanation": "Deze actie kon niet voltooid worden. Probeer opnieuw."
|
||||||
"forbidden": "Je hebt geen toegang tot dit onderdeel.",
|
},
|
||||||
"notFound": "Dit record kon niet gevonden worden.",
|
"codes": {
|
||||||
"networkUnavailable": "De verbinding met de server is momenteel niet beschikbaar."
|
"VEHICLE_NOT_FOUND": {
|
||||||
|
"title": "Voertuig niet gevonden",
|
||||||
|
"explanation": "Dit voertuig kon niet gevonden worden. Het is mogelijk verwijderd, of de referentie klopt niet."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_FOUND": {
|
||||||
|
"title": "Boeking niet gevonden",
|
||||||
|
"explanation": "Deze boeking kon niet gevonden worden. Ze is mogelijk verwijderd, of de referentie klopt niet."
|
||||||
|
},
|
||||||
|
"CUSTOMER_NOT_FOUND": {
|
||||||
|
"title": "Klant niet gevonden",
|
||||||
|
"explanation": "Dit klantrecord kon niet gevonden worden."
|
||||||
|
},
|
||||||
|
"ENTITY_NOT_FOUND": {
|
||||||
|
"title": "Record niet gevonden",
|
||||||
|
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
|
||||||
|
},
|
||||||
|
"EVENT_NOT_FOUND": {
|
||||||
|
"title": "Automatiseringsopdracht niet gevonden",
|
||||||
|
"explanation": "Deze automatiseringsgebeurtenis kon niet gevonden worden."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_FOUND": {
|
||||||
|
"title": "Datakwaliteitsprobleem niet gevonden",
|
||||||
|
"explanation": "Dit datakwaliteitsprobleem kon niet gevonden worden."
|
||||||
|
},
|
||||||
|
"ISSUE_NOT_OPEN": {
|
||||||
|
"title": "Probleem is niet meer openstaand",
|
||||||
|
"explanation": "Dit probleem is al opgelost, uitgesteld of verworpen.",
|
||||||
|
"nextStep": "Vernieuw de pagina om de huidige status te zien."
|
||||||
|
},
|
||||||
|
"BOOKING_NOT_ACTIVE": {
|
||||||
|
"title": "Boeking is niet actief",
|
||||||
|
"explanation": "Enkel een gereserveerde of actieve boeking kan voor deze actie gebruikt worden."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_STATE": {
|
||||||
|
"title": "Boeking heeft de verkeerde status",
|
||||||
|
"explanation": "De huidige status van deze boeking laat deze actie niet toe."
|
||||||
|
},
|
||||||
|
"NOT_RETRYABLE": {
|
||||||
|
"title": "Deze opdracht kan niet opnieuw geprobeerd worden",
|
||||||
|
"explanation": "Enkel een mislukte aflevering kan opnieuw geprobeerd worden."
|
||||||
|
},
|
||||||
|
"CONFLICT_STILL_PRESENT": {
|
||||||
|
"title": "Conflict is niet opgelost",
|
||||||
|
"explanation": "Het toepassen van deze wijziging heeft het onderliggende conflict niet opgelost.",
|
||||||
|
"nextStep": "Bekijk de aanbeveling opnieuw voordat je het nogmaals probeert."
|
||||||
|
},
|
||||||
|
"OVERLAP_STILL_PRESENT": {
|
||||||
|
"title": "Overlap is niet opgelost",
|
||||||
|
"explanation": "Het blokkeren van deze boeking heeft de overlap niet weggenomen; er blijft een andere verbintenis bestaan."
|
||||||
|
},
|
||||||
|
"EMPTY_VALUE": {
|
||||||
|
"title": "Een verplicht veld is leeg",
|
||||||
|
"explanation": "Dit veld mag niet leeg blijven.",
|
||||||
|
"nextStep": "Vul een waarde in en probeer opnieuw."
|
||||||
|
},
|
||||||
|
"NO_FIELDS_PROVIDED": {
|
||||||
|
"title": "Geen wijzigingen opgegeven",
|
||||||
|
"explanation": "Er moet minstens één veld ingevuld worden om verder te gaan."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD": {
|
||||||
|
"title": "Veld hier niet toegelaten",
|
||||||
|
"explanation": "Eén van de opgegeven velden is niet toegelaten voor deze actie."
|
||||||
|
},
|
||||||
|
"INVALID_FIELD_OVERRIDE": {
|
||||||
|
"title": "Veld kan niet samengevoegd worden",
|
||||||
|
"explanation": "Eén van de gekozen velden kan niet gebruikt worden in deze samenvoeging."
|
||||||
|
},
|
||||||
|
"INVALID_SURVIVOR": {
|
||||||
|
"title": "Ongeldige keuze",
|
||||||
|
"explanation": "Het record dat je wil behouden moet één van de twee vergeleken records zijn."
|
||||||
|
},
|
||||||
|
"INVALID_BOOKING_REFERENCE": {
|
||||||
|
"title": "Boekingreferentie hier niet geldig",
|
||||||
|
"explanation": "De gekozen boeking hoort niet bij de boekingen die aan dit probleem gekoppeld zijn."
|
||||||
|
},
|
||||||
|
"INVALID_EVENT_ID": {
|
||||||
|
"title": "Ongeldige opdrachtreferentie",
|
||||||
|
"explanation": "Deze referentie naar een automatiseringsopdracht is niet geldig."
|
||||||
|
},
|
||||||
|
"INVALID_IDEMPOTENCY_KEY": {
|
||||||
|
"title": "Aanvraag kon niet veilig herhaald worden",
|
||||||
|
"explanation": "De trackingsleutel van deze aanvraag is niet geldig.",
|
||||||
|
"nextStep": "Herlaad de pagina en probeer opnieuw."
|
||||||
|
},
|
||||||
|
"IDEMPOTENCY_KEY_REUSED": {
|
||||||
|
"title": "Deze actie werd al ingediend",
|
||||||
|
"explanation": "Een identieke aanvraag werd al verwerkt, met een ander resultaat.",
|
||||||
|
"nextStep": "Herlaad de pagina om de huidige status te zien voordat je het nogmaals probeert."
|
||||||
|
},
|
||||||
|
"CORRECTED_VALUE_REQUIRED": {
|
||||||
|
"title": "Een gecorrigeerde waarde is vereist",
|
||||||
|
"explanation": "Voor de keuze \"stand corrigeren\" moet je de gecorrigeerde waarde invullen."
|
||||||
|
},
|
||||||
|
"CORRECTION_BELOW_CANONICAL": {
|
||||||
|
"title": "Correctie ligt onder de bevestigde stand",
|
||||||
|
"explanation": "Een gecorrigeerde kilometerstand mag nooit lager zijn dan de laatst bevestigde stand."
|
||||||
|
},
|
||||||
|
"NOT_A_DUPLICATE_ISSUE": {
|
||||||
|
"title": "Verkeerd probleemtype",
|
||||||
|
"explanation": "Deze actie geldt enkel voor problemen van het type mogelijke dubbele klant."
|
||||||
|
},
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE": {
|
||||||
|
"title": "Verkeerd probleemtype",
|
||||||
|
"explanation": "Deze actie geldt enkel voor problemen met een ontbrekend verplicht veld."
|
||||||
|
},
|
||||||
|
"NOT_AN_ODOMETER_ISSUE": {
|
||||||
|
"title": "Verkeerd probleemtype",
|
||||||
|
"explanation": "Deze actie geldt enkel voor problemen met een afwijkende kilometerstand."
|
||||||
|
},
|
||||||
|
"NOT_AN_OVERLAP_ISSUE": {
|
||||||
|
"title": "Verkeerd probleemtype",
|
||||||
|
"explanation": "Deze actie geldt enkel voor problemen met een overlappende boeking."
|
||||||
|
},
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE": {
|
||||||
|
"title": "Verkeerd probleemtype",
|
||||||
|
"explanation": "Deze actie geldt enkel voor problemen met een statusconflict van het voertuig."
|
||||||
|
},
|
||||||
|
"UNSUPPORTED_ENTITY": {
|
||||||
|
"title": "Niet ondersteund voor dit recordtype",
|
||||||
|
"explanation": "Deze actie is niet beschikbaar voor dit soort record."
|
||||||
|
},
|
||||||
|
"MANUAL_REVIEW_REQUIRED": {
|
||||||
|
"title": "Handmatige beoordeling vereist",
|
||||||
|
"explanation": "De feiten voor dit voertuig spreken elkaar tegen, waardoor geen automatische wijziging veilig is.",
|
||||||
|
"nextStep": "Stel dit probleem uit of verwerp het, of onderzoek het handmatig."
|
||||||
|
},
|
||||||
|
"NO_CONFLICT_DETECTED": {
|
||||||
|
"title": "Niets om toe te passen",
|
||||||
|
"explanation": "De huidige status komt al overeen met de feiten, dus er is niets meer om toe te passen."
|
||||||
|
},
|
||||||
|
"RECOMMENDATION_STALE": {
|
||||||
|
"title": "De situatie is intussen gewijzigd",
|
||||||
|
"explanation": "De onderliggende feiten zijn gewijzigd sinds deze aanbeveling getoond werd.",
|
||||||
|
"nextStep": "Bekijk de aanbeveling opnieuw voordat je ze toepast."
|
||||||
|
},
|
||||||
|
"UNAUTHORIZED_SERVICE": {
|
||||||
|
"title": "Dienstautorisatie mislukt",
|
||||||
|
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"401": {
|
||||||
|
"title": "Sessie verlopen",
|
||||||
|
"explanation": "Je sessie is verlopen.",
|
||||||
|
"nextStep": "Log opnieuw in."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"title": "Geen toegang",
|
||||||
|
"explanation": "Je hebt geen toestemming om deze actie uit te voeren."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "Niet gevonden",
|
||||||
|
"explanation": "Dit record kon niet gevonden worden."
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"title": "Deze actie is niet meer mogelijk",
|
||||||
|
"explanation": "De onderliggende status is gewijzigd sinds deze pagina geladen werd.",
|
||||||
|
"nextStep": "Vernieuw de pagina en probeer opnieuw."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"title": "Validatie mislukt",
|
||||||
|
"explanation": "De opgegeven informatie is niet geldig."
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"title": "Serverfout",
|
||||||
|
"explanation": "Er is iets misgegaan aan onze kant."
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Verbinding niet beschikbaar",
|
||||||
|
"explanation": "De verbinding met de server is momenteel niet beschikbaar.",
|
||||||
|
"nextStep": "Controleer je verbinding en probeer opnieuw."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||