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")
+7
View File
@@ -48,6 +48,7 @@ def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
)[:2000]
row.last_error_code = "staleLeaseRecovered"
db.commit()
return len(rows)
finally:
@@ -111,8 +112,10 @@ def _deliver_one(event_id: uuid.UUID) -> None:
finally:
db.close()
error_code: str | None
if wire_event is None:
success, error, body = False, payload_error, None
error_code = "malformedPayload"
else:
try:
response = httpx.post(
@@ -124,9 +127,11 @@ def _deliver_one(event_id: uuid.UUID) -> None:
body = response.json()
success = bool(body.get("ok", True))
error = None if success else f"n8n reported failure: {body}"
error_code = None if success else "remoteReportedFailure"
except httpx.HTTPError as exc:
success = False
error = f"{type(exc).__name__}: {exc}"
error_code = "connectionError"
body = None
db = SessionLocal()
@@ -138,10 +143,12 @@ def _deliver_one(event_id: uuid.UUID) -> None:
if success:
event.delivery_status = "succeeded"
event.last_error = None
event.last_error_code = None
event.next_attempt_at = None
event.external_run_id = str((body or {}).get("event_id", event_id))
else:
event.last_error = (error or "delivery failed")[:2000]
event.last_error_code = error_code or "unknownError"
if event.attempts >= settings.n8n_max_attempts:
event.delivery_status = "failed"
event.next_attempt_at = None
+34 -7
View File
@@ -28,19 +28,41 @@ def _next_public_ref(db: Session) -> str:
def _derive_vehicle_status_with_reason(
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
) -> tuple[str, str]:
) -> tuple[str, str, dict[str, str | int]]:
# Stable, localizable codes + params -- the backend never emits prose here. The
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
# raw-English fallback strings live only in status_reason (shown under "Technical
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
if body.damage_reported and body.technical_warning:
return "blocked", "Damage and a technical warning were both reported on return."
return (
"blocked",
"returnBlockedDamageAndTechnical",
{},
)
if body.damage_reported:
return "blocked", "Damage was reported on return."
return "blocked", "returnBlockedDamage", {}
if body.technical_warning:
return "blocked", "A technical warning was reported on return."
return "blocked", "returnBlockedTechnicalWarning", {}
if new_odometer >= vehicle.next_service_km:
return (
"maintenance",
f"Odometer reached the {vehicle.next_service_km:,} km service threshold.",
"returnServiceThresholdReached",
{"threshold_km": vehicle.next_service_km},
)
return "cleaning", "No damage, technical warning or service threshold; routed to cleaning."
return "cleaning", "returnRoutedToCleaning", {}
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
"returnBlockedDamageAndTechnical": (
"Damage and a technical warning were both reported on return."
),
"returnBlockedDamage": "Damage was reported on return.",
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
"returnServiceThresholdReached": "Odometer reached the service threshold.",
"returnRoutedToCleaning": (
"No damage, technical warning or service threshold; routed to cleaning."
),
}
@dataclass
@@ -51,6 +73,8 @@ class ReturnEvaluation:
resulting_odometer_km: int
resulting_vehicle_status: str
status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int]
would_create_quality_issue: bool
attention_reasons: list[str]
next_booking_risk: dict | None
@@ -64,9 +88,10 @@ def evaluate_return(
preview and commit can never drift apart."""
odometer_regression = body.end_odometer_km < vehicle.odometer_km
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
resulting_status, status_reason = _derive_vehicle_status_with_reason(
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
body, vehicle, resulting_odometer_km
)
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
attention_reasons = []
if body.damage_reported:
@@ -101,6 +126,8 @@ def evaluate_return(
resulting_odometer_km=resulting_odometer_km,
resulting_vehicle_status=resulting_status,
status_reason=status_reason,
status_reason_code=status_reason_code,
status_reason_params=status_reason_params,
would_create_quality_issue=odometer_regression,
attention_reasons=attention_reasons,
next_booking_risk=next_booking_risk,
+218
View File
@@ -0,0 +1,218 @@
"""The single authoritative vehicle-status evaluator.
Used by the data-quality scanner (detection), the status-recommendation preview
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
recommendation can never structurally disagree (see docs/fleet-ops-correction/
vehicle-status-decision-table.md for the full decision table and rationale).
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
never from a proxy like "does some other high-severity issue happen to be open". It is
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
the same vehicle never changes what this function returns, because it never looks at
issue history, only at the vehicle's/bookings' current state.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
# Every code below is a stable, localizable identifier -- see
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
# human-language mapping in all three supported locales. The backend never emits prose.
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
@dataclass
class VehicleStatusFacts:
active_booking_refs: list[str] = field(default_factory=list)
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
service_threshold_reached: bool = False
odometer_km: int = 0
next_service_km: int = 0
open_booking_overlap_issue_ref: str | None = None
@property
def has_active_rental(self) -> bool:
return len(self.active_booking_refs) > 0
@property
def has_booking_conflict(self) -> bool:
return (
len(self.overlapping_booking_pairs) > 0
or self.open_booking_overlap_issue_ref is not None
)
def as_dict(self) -> dict:
return {
"active_booking_refs": self.active_booking_refs,
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
"service_threshold_reached": self.service_threshold_reached,
"odometer_km": self.odometer_km,
"next_service_km": self.next_service_km,
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
}
@dataclass
class VehicleStatusRecommendation:
current_status: str
recommended_status: str | None
recommendation_code: str
safe_to_apply: bool
manual_review_required: bool
facts: VehicleStatusFacts
blocking_reasons: list[str]
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
ordered = sorted(bookings, key=lambda b: b.starts_at)
pairs: list[tuple[Booking, Booking]] = []
for i, first in enumerate(ordered):
for second in ordered[i + 1 :]:
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
pairs.append((first, second))
return pairs
def gather_vehicle_status_facts(
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
) -> VehicleStatusFacts:
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
derived from another issue's mere existence (only a *specific* booking_overlap
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
reserved_or_active = list(
db.scalars(
select(Booking).where(
Booking.vehicle_id == vehicle.id,
Booking.status.in_(["reserved", "active"]),
)
).all()
)
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
overlap_pairs = [
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
]
overlap_issue_query = select(DataQualityIssue.public_ref).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "booking_overlap",
)
if exclude_issue_id is not None:
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
open_overlap_ref = db.scalar(overlap_issue_query)
return VehicleStatusFacts(
active_booking_refs=active_refs,
overlapping_booking_pairs=overlap_pairs,
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
open_booking_overlap_issue_ref=open_overlap_ref,
)
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
"""A short digest of exactly the facts the recommendation was based on, plus the
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
facts and rejects the request if it doesn't match the token the client last saw --
the frontend must never assume a previously-shown preview is still valid without the
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
return digest[:16]
def evaluate_vehicle_status(
vehicle: Vehicle, facts: VehicleStatusFacts
) -> VehicleStatusRecommendation:
"""Pure decision logic over already-gathered facts -- see
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
never queries the database itself (call gather_vehicle_status_facts first), so it is
trivial to unit-test every branch in isolation."""
current = vehicle.operational_status
blocking_reasons: list[str] = []
if facts.service_threshold_reached:
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
if facts.has_booking_conflict:
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
# Already being in maintenance is itself a real blocking fact -- an active
# booking never overrides it. This is exactly the forbidden shortcut this
# evaluator must never take (maintenance + active booking -> auto "rented").
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
def result(
recommended: str | None, code: str, *, safe: bool, manual: bool
) -> VehicleStatusRecommendation:
return VehicleStatusRecommendation(
current_status=current,
recommended_status=recommended,
recommendation_code=code,
safe_to_apply=safe,
manual_review_required=manual,
facts=facts,
blocking_reasons=blocking_reasons,
)
if facts.has_active_rental and not blocking_reasons:
if current == "rented":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
)
if facts.has_active_rental and blocking_reasons:
# Explicitly forbidden shortcut this evaluator must never take: an active
# booking is not proof the vehicle should be "rented" when a real blocking
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
# This is a real contradiction in the underlying facts, not something safe to
# resolve automatically.
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
if facts.service_threshold_reached:
if current == "maintenance":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
)
if facts.has_booking_conflict:
if current == "blocked":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
)
# No active rental, no maintenance need, no booking conflict.
if current in ("available", "cleaning", "blocked"):
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
if current == "rented":
return result(
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
)
if current == "maintenance":
# No positive fact confirms maintenance is actually finished (no completed
# service record is tracked here) -- clearing "maintenance" without such a
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
# Releasing a vehicle from maintenance remains an explicit, manual decision.
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)