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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
18344bc8b7
commit
6deb95524d
@@ -249,28 +249,74 @@ def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
|
||||
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(
|
||||
"/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
|
||||
|
||||
|
||||
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):
|
||||
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(
|
||||
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
|
||||
body = response.json()
|
||||
assert body["issue"]["status"] == "resolved"
|
||||
assert body["applied_status"]
|
||||
assert body["reason"]
|
||||
assert body["applied_status"] == preview["recommended_status"]
|
||||
assert body["reason_code"] == preview["recommendation_code"]
|
||||
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||
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):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
||||
@@ -365,6 +411,72 @@ def test_manual_scan_records_audit_event(ops_client):
|
||||
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 _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 -- resolving them in either order must land the
|
||||
# vehicle in the same final, safe state (see docs/fleet-ops-correction/
|
||||
# current-gap-audit.md §6-7 and vehicle-status-decision-table.md).
|
||||
|
||||
# Order A: resolve the booking overlap first, then the status conflict.
|
||||
_reset_demo(ops_client)
|
||||
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
|
||||
status_issue_a = _first_open(ops_client, "vehicle_status_conflict")
|
||||
result_a = _apply_status_recommendation(ops_client, status_issue_a["public_ref"])
|
||||
vehicle_a = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||
|
||||
# Order B: resolve the status conflict first, then the booking overlap.
|
||||
_reset_demo(ops_client)
|
||||
status_issue_b = _first_open(ops_client, "vehicle_status_conflict")
|
||||
result_b = _apply_status_recommendation(ops_client, status_issue_b["public_ref"])
|
||||
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
|
||||
vehicle_b = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||
|
||||
assert result_a["applied_status"] == result_b["applied_status"], (
|
||||
"resolving the booking overlap before vs. after the status conflict recommended "
|
||||
"a different status -- the recommendation must not depend on issue order"
|
||||
)
|
||||
assert vehicle_a["operational_status"] == vehicle_b["operational_status"]
|
||||
# Never auto-"available" and never auto-"rented" as a side effect of this scenario --
|
||||
# MO-016 has no active rental in either order, only reserved bookings.
|
||||
assert vehicle_a["operational_status"] not in ("rented",)
|
||||
|
||||
_reset_demo(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
|
||||
# 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.external_run_id == str(event_id)
|
||||
assert event.last_error is None
|
||||
assert event.last_error_code is None
|
||||
|
||||
|
||||
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.next_attempt_at is not None
|
||||
assert "simulated connection failure" in event.last_error
|
||||
assert event.last_error_code == "connectionError"
|
||||
|
||||
|
||||
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.attempts == 1
|
||||
assert "Malformed outbox payload" in event.last_error
|
||||
assert event.last_error_code == "malformedPayload"
|
||||
|
||||
|
||||
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.attempts == 2
|
||||
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.
|
||||
claimed = dispatcher._claim_due_events()
|
||||
|
||||
@@ -11,6 +11,10 @@ def test_search_finds_a_vehicle_by_reference(ops_client):
|
||||
assert match is not None
|
||||
assert match["label"] == "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):
|
||||
@@ -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):
|
||||
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):
|
||||
|
||||
@@ -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(Customer)) == 180
|
||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan.
|
||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 26
|
||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
||||
# 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(User)) == 2
|
||||
finally:
|
||||
@@ -138,6 +142,7 @@ def test_seed_scenario_s5_failed_workflow_run():
|
||||
assert failed.delivery_status == "failed"
|
||||
assert failed.attempts >= 1
|
||||
assert failed.last_error
|
||||
assert failed.last_error_code == "connectionError"
|
||||
finally:
|
||||
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)
|
||||
Reference in New Issue
Block a user