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
+129 -90
View File
@@ -15,6 +15,13 @@ from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.services.audit import record_audit_event
from app.services.vehicle_status import (
RECOMMENDATION_CODE_NO_CONFLICT,
VehicleStatusRecommendation,
compute_recommendation_token,
evaluate_vehicle_status,
gather_vehicle_status_facts,
)
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
@@ -69,6 +76,7 @@ def _open_issue(
summary: str,
entity_ref: str,
related_refs: list[str],
signals: list[dict] | None = None,
) -> None:
if _has_open_issue(db, rule_type, entity_type, entity_id):
return
@@ -87,10 +95,14 @@ def _open_issue(
)
.order_by(DataQualityIssue.detected_at.desc())
)
# `summary` is kept as a technical-fallback string (shown only under "Technical
# details"); `signals` is the stable, localizable structure the frontend renders as
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
evidence: dict = {
"summary": summary,
"entity_ref": entity_ref,
"related_refs": related_refs,
"signals": signals or [],
}
if previous is not None:
evidence["reopened_from"] = previous.public_ref
@@ -121,22 +133,29 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
for i, a in enumerate(customers):
for b in customers[i + 1 :]:
score = 0
signals = []
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append("exact email")
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
signals.append("exact phone")
signals.append({"code": "duplicate.exact_phone"})
summary_parts.append("exact phone")
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
score += 10
signals.append("exact postal code")
signals.append({"code": "duplicate.same_postal_code"})
summary_parts.append("exact postal code")
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append("similar name")
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
_open_issue(
@@ -146,9 +165,10 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(signals) + f" (score {score})",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
@@ -170,6 +190,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}",
entity_ref=customer.public_ref,
related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
)
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
@@ -185,6 +206,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}",
entity_ref=vehicle.public_ref,
related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
)
@@ -213,50 +235,47 @@ def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
entity_ref=vehicle.public_ref,
related_refs=[first.public_ref, second.public_ref],
signals=[
{
"code": "overlap.reserved_bookings",
"params": {"refs": [first.public_ref, second.public_ref]},
}
],
)
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
# so detection and resolution can never structurally disagree -- see
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
vehicles = db.scalars(select(Vehicle)).all()
active_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars(select(Booking).where(Booking.status == "active")).all():
active_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
open_high_by_vehicle = {
row[0]
for row in db.execute(
select(DataQualityIssue.entity_id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
)
).all()
}
for vehicle in vehicles:
has_active_booking = vehicle.id in active_by_vehicle
reason = None
if vehicle.operational_status == "available" and has_active_booking:
reason = "marked available while an active booking exists"
elif vehicle.operational_status == "rented" and not has_active_booking:
reason = "marked rented without an active booking"
elif vehicle.operational_status == "available" and vehicle.id in open_high_by_vehicle:
reason = "marked available while a high-severity quality issue is open"
elif vehicle.operational_status == "maintenance" and has_active_booking:
reason = "marked maintenance while an active booking exists"
facts = gather_vehicle_status_facts(db, vehicle)
recommendation = evaluate_vehicle_status(vehicle, facts)
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
continue
if reason:
_open_issue(
db,
scan,
rule_type="vehicle_status_conflict",
entity_type="vehicle",
entity_id=vehicle.id,
severity="high",
summary=f"Vehicle {reason}",
entity_ref=vehicle.public_ref,
related_refs=[],
)
signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
summary = (
f"Recommended status: {recommendation.recommended_status}"
if recommendation.recommended_status
else "Manual review required: active rental conflicts with a blocking condition"
)
_open_issue(
db,
scan,
rule_type="vehicle_status_conflict",
entity_type="vehicle",
entity_id=vehicle.id,
severity="high",
summary=summary,
entity_ref=vehicle.public_ref,
related_refs=[
*facts.active_booking_refs,
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
],
signals=signals,
)
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
@@ -295,6 +314,17 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
),
entity_ref=vehicle.public_ref,
related_refs=[earlier.public_ref, later.public_ref],
signals=[
{
"code": "odometer.regression",
"params": {
"later_ref": later.public_ref,
"later_km": later.end_odometer_km,
"earlier_ref": earlier.public_ref,
"earlier_km": earlier.end_odometer_km,
},
}
],
)
break
@@ -650,28 +680,7 @@ def resolve_booking_overlap(
return issue
def _recommend_vehicle_status(
operational_status: str, has_active_booking: bool, has_open_high_issue: bool
) -> tuple[str, str] | None:
"""The single authoritative recommendation function for vehicle_status_conflict,
mirroring the exact conditions `_scan_vehicle_status_conflicts` flags."""
if operational_status == "available" and has_active_booking:
return "rented", "An active booking exists; the vehicle should be marked rented."
if operational_status == "rented" and not has_active_booking:
return "available", "No active booking exists; the vehicle should be marked available."
if operational_status == "available" and has_open_high_issue:
return "blocked", "A high-severity quality issue is open; the vehicle should be blocked."
if operational_status == "maintenance" and has_active_booking:
return (
"rented",
"An active booking exists despite the maintenance status; it should be rented.",
)
return None
def apply_recommended_status(
db: Session, public_ref: str, actor: CurrentUser
) -> tuple[DataQualityIssue, str, str]:
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "vehicle_status_conflict":
raise AppError(
@@ -679,47 +688,77 @@ def apply_recommended_status(
"This issue is not a vehicle_status_conflict issue.",
status_code=409,
)
return issue
def preview_vehicle_status_recommendation(
db: Session, public_ref: str
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
"""Non-mutating: computes and returns the recommendation only. Never resolves the
issue, never writes an audit event, never queues automation -- safe to call as often
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
issue = _load_vehicle_status_conflict_issue(db, public_ref)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
recommendation = evaluate_vehicle_status(vehicle, facts)
token = compute_recommendation_token(vehicle, facts)
return issue, vehicle, recommendation, token
def apply_recommended_status(
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
) -> tuple[DataQualityIssue, str, str]:
issue = _load_vehicle_status_conflict_issue(db, public_ref)
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
# (or return/checkout) can't race between our fact-gathering and the write below.
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
has_active_booking = (
db.scalar(
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
recommendation = evaluate_vehicle_status(vehicle, facts)
current_token = compute_recommendation_token(vehicle, facts)
if current_token != expected_token:
raise AppError(
"RECOMMENDATION_STALE",
"The underlying facts changed since this recommendation was shown; "
"review the recommendation again before applying it.",
status_code=409,
)
is not None
)
has_open_high_issue = (
db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
DataQualityIssue.id != issue.id,
)
if recommendation.manual_review_required or not recommendation.safe_to_apply:
raise AppError(
"MANUAL_REVIEW_REQUIRED",
"This vehicle's state requires manual review; no automatic status change is safe.",
status_code=409,
)
is not None
)
recommendation = _recommend_vehicle_status(
vehicle.operational_status, has_active_booking, has_open_high_issue
)
if recommendation is None:
if recommendation.recommended_status is None:
raise AppError(
"NO_CONFLICT_DETECTED",
"The current vehicle state no longer conflicts; nothing to apply.",
status_code=409,
)
new_status, reason = recommendation
new_status = recommendation.recommended_status
reason_code = recommendation.recommendation_code
before = {"operational_status": vehicle.operational_status}
vehicle.operational_status = new_status
vehicle.version += 1
# Re-validate: the same recommendation function must find no further conflict.
if _recommend_vehicle_status(new_status, has_active_booking, has_open_high_issue) is not None:
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
# applying this change actually leaves no conflict -- never trust the pre-computed
# recommendation alone for the post-condition.
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
post_check = evaluate_vehicle_status(vehicle, post_facts)
if post_check.recommendation_code not in (
RECOMMENDATION_CODE_NO_CONFLICT,
):
raise AppError(
"CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.",
@@ -737,7 +776,7 @@ def apply_recommended_status(
correlation_id=correlation_id,
before=before,
after={"operational_status": vehicle.operational_status},
metadata={"issue_ref": issue.public_ref, "reason": reason},
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
)
issue.status = "resolved"
@@ -755,7 +794,7 @@ def apply_recommended_status(
after={"status": "resolved"},
)
db.commit()
return issue, new_status, reason
return issue, new_status, reason_code
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")