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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user