- 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>
5.7 KiB
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 inmaintenanceis 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 torentedsilently past a real blocker. Therentedrecommendation 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. Leavingmaintenancerequires 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 inmaintenancewith no active rental and no other blocker staysvehicle.no_conflict(left alone) rather than being auto-promoted toavailable. - Every branch re-derives facts; nothing is cached.
blocking_reasonsis always computed fresh fromservice_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.