Files
MobilityOps/docs/fleet-ops-correction/vehicle-status-decision-table.md
T
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
  used identically by the data-quality scanner, a new non-mutating status-recommendation
  preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
  revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
  "maintenance + active booking -> auto rented" shortcut. Frontend
  DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
  why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
  <status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
  after the status-conflict issue now converges on the same final vehicle status,
  proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
  backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
  appeared in locale prose; add a permanent test guarding against a translation file
  ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
  reasons, audit field/actor-type labels, automation last_error, and search
  section/vehicle/booking/issue results all now carry codes the frontend localizes,
  with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
  decision table documenting the evaluator's rules and safe-status principles.

148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:37:34 +02:00

91 lines
5.7 KiB
Markdown

# 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`.