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
@@ -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=(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# The visible product name is fixed and never translated or configured per-deployment --
|
||||
# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers
|
||||
# (package name, Compose project, database name, repository) intentionally remain
|
||||
# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title).
|
||||
PRODUCT_NAME = "Fleet Ops"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ from app.api.routers import (
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.core.config import PRODUCT_NAME, get_settings
|
||||
from app.core.errors import AppError, error_body
|
||||
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||
|
||||
@@ -33,7 +33,7 @@ async def lifespan(_app: FastAPI):
|
||||
stop_background_dispatcher()
|
||||
|
||||
|
||||
app = FastAPI(title="MobilityOps API", version="0.1.0", lifespan=lifespan)
|
||||
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -26,4 +26,9 @@ class OutboxEvent(TimestampMixin, Base):
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
# Stable, localizable classification of last_error -- the frontend renders a
|
||||
# localized summary from this code as the primary text and shows last_error itself
|
||||
# only under "Technical details" (section 10 of docs/fleet-ops-correction/
|
||||
# current-gap-audit.md). Kept alongside the raw message for backward compatibility.
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(60))
|
||||
external_run_id: Mapped[str | None] = mapped_column(String(120))
|
||||
|
||||
+30
-2
@@ -83,6 +83,8 @@ class ReturnPreviewResult(BaseModel):
|
||||
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: NextBookingRisk | None
|
||||
@@ -157,16 +159,41 @@ class ResolveOverlapRequest(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class VehicleStatusFactsOut(BaseModel):
|
||||
active_booking_refs: list[str]
|
||||
overlapping_booking_pairs: list[list[str]]
|
||||
service_threshold_reached: bool
|
||||
odometer_km: int
|
||||
next_service_km: int
|
||||
open_booking_overlap_issue_ref: str | None = None
|
||||
|
||||
|
||||
class StatusRecommendationOut(BaseModel):
|
||||
current_status: str
|
||||
recommended_status: str | None
|
||||
recommendation_code: str
|
||||
safe_to_apply: bool
|
||||
manual_review_required: bool
|
||||
facts: VehicleStatusFactsOut
|
||||
blocking_reasons: list[str]
|
||||
recommendation_token: str
|
||||
|
||||
|
||||
class ApplyRecommendedStatusRequest(BaseModel):
|
||||
recommendation_token: str
|
||||
|
||||
|
||||
class ApplyRecommendedStatusResult(BaseModel):
|
||||
issue: DataQualityIssueOut
|
||||
applied_status: str
|
||||
reason: str
|
||||
reason_code: str
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||
label: str
|
||||
detail: str
|
||||
detail_code: str
|
||||
detail_params: dict[str, str] = {}
|
||||
link: str
|
||||
|
||||
|
||||
@@ -269,6 +296,7 @@ class AutomationRunOut(BaseModel):
|
||||
status: str
|
||||
attempts: int
|
||||
last_error: str | None
|
||||
last_error_code: str | None
|
||||
occurred_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -287,6 +287,9 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"attempts": int(row["attempts"]),
|
||||
"next_attempt_at": None,
|
||||
"last_error": row["last_error"] or None,
|
||||
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
||||
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
||||
"last_error_code": "connectionError" if row["last_error"] else None,
|
||||
"external_run_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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