- 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>
91 lines
5.7 KiB
Markdown
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`.
|