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:
NuklearRabbit
2026-08-03 21:37:34 +02:00
co-authored by Claude Sonnet 5
parent 18344bc8b7
commit 6deb95524d
68 changed files with 1734 additions and 225 deletions
+2
View File
@@ -89,6 +89,8 @@ def preview_return(
resulting_odometer_km=evaluation.resulting_odometer_km,
resulting_vehicle_status=evaluation.resulting_vehicle_status,
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,
attention_reasons=evaluation.attention_reasons,
next_booking_risk=(
+1
View File
@@ -108,6 +108,7 @@ def get_dashboard(
status=r.delivery_status,
attempts=r.attempts,
last_error=r.last_error,
last_error_code=r.last_error_code,
occurred_at=r.occurred_at,
)
for r in recent
+32 -2
View File
@@ -11,6 +11,7 @@ from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle
from app.schemas import (
ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult,
CurrentUser,
DataQualityIssueDetailOut,
@@ -21,11 +22,14 @@ from app.schemas import (
ResolveOdometerRegressionRequest,
ResolveOverlapRequest,
ScanResultOut,
StatusRecommendationOut,
VehicleStatusFactsOut,
)
from app.services.data_quality import (
apply_recommended_status,
defer_issue,
merge_customers,
preview_vehicle_status_recommendation,
provide_missing_fields,
reject_issue,
resolve_booking_overlap,
@@ -241,17 +245,43 @@ def resolve_overlap(
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(
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
)
def apply_status(
public_ref: str,
body: ApplyRecommendedStatusRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> 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(
issue=_to_out(issue), applied_status=applied_status, reason=reason
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
)
+58 -29
View File
@@ -12,53 +12,81 @@ from app.schemas import CurrentUser, SearchResponse, SearchResultItem
router = APIRouter(prefix="/api/v1/search", tags=["search"])
# Static application sections. 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.
# Static application sections. `id` is a stable code matching navigation.json's
# `items.*` keys -- the frontend localizes both the section label and its one-line
# 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] = [
{
"label": "Overview",
"detail": "Operations dashboard",
"id": "overview",
"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",
"detail": "Vehicle registry",
"id": "fleet",
"link": "/vehicles",
"terms": ["fleet", "vehicle", "vehicles"],
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
},
{
"label": "Bookings",
"detail": "Rental bookings",
"id": "bookings",
"link": "/bookings",
"terms": ["booking", "bookings", "rental"],
"terms": [
"booking",
"bookings",
"rental",
"boeking",
"boekingen",
"verhuur",
"réservation",
"réservations",
"location",
],
},
{
"label": "Data quality",
"detail": "Quality workbench",
"id": "quality",
"link": "/data-quality",
"terms": ["quality", "data quality", "issues"],
"terms": [
"quality",
"data quality",
"issues",
"kwaliteit",
"datakwaliteit",
"problemen",
"qualité",
"problèmes",
],
"role": "operations_manager",
},
{
"label": "Knowledge",
"detail": "Procedure assistant",
"id": "knowledge",
"link": "/knowledge",
"terms": ["knowledge", "procedures"],
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
},
{
"label": "Integrations",
"detail": "Automation and integration status",
"id": "integrations",
"link": "/automation",
"terms": ["automation", "integrations", "systems", "n8n"],
"terms": [
"automation",
"integrations",
"systems",
"n8n",
"automatisering",
"integraties",
"systemen",
"automatisation",
"intégrations",
"systèmes",
],
"role": "operations_manager",
},
{
"label": "Audit trail",
"detail": "Audit history",
"id": "audit",
"link": "/audit",
"terms": ["audit", "history"],
"terms": ["audit", "history", "geschiedenis", "historique"],
"role": "operations_manager",
},
]
@@ -83,8 +111,8 @@ def search(
results.append(
SearchResultItem(
type="section",
label=section["label"],
detail=section["detail"],
label=section["id"],
detail_code=section["id"],
link=section["link"],
)
)
@@ -108,7 +136,8 @@ def search(
SearchResultItem(
type="vehicle",
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}",
)
)
@@ -120,7 +149,7 @@ def search(
SearchResultItem(
type="booking",
label=b.public_ref,
detail=b.status,
detail_code=b.status,
link=f"/bookings/{b.public_ref}",
)
)
@@ -138,7 +167,7 @@ def search(
SearchResultItem(
type="data_quality_issue",
label=i.public_ref,
detail=i.rule_type.replace("_", " "),
detail_code=i.rule_type,
link=f"/data-quality/{i.public_ref}",
)
)
+1
View File
@@ -23,6 +23,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
status=event.delivery_status,
attempts=event.attempts,
last_error=event.last_error,
last_error_code=event.last_error_code,
occurred_at=event.occurred_at,
)