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