feat(returns): add authoritative return preview

The return-review step predicted operational consequences independently in
the frontend, and got it wrong: damage or a technical warning was described
as routing to "maintenance" when the actual domain rule (returns.py) routes
it to "blocked", and the no-contradiction case was described as becoming
"available" when the vehicle actually always goes to "cleaning" first
(only reaching "maintenance" if the service threshold was crossed).

Extract the evaluation returns.py already performed inline into a pure
evaluate_return() function with no writes -- resulting status (with an
explanation), odometer regression, would-create-quality-issue,
next-booking-risk -- and share it between a new non-mutating
POST /bookings/{ref}/return-preview endpoint and the existing commit path,
so preview and commit can never drift apart again. The result screen also
now distinguishes local commit success from n8n delivery (still queued/
unconfirmed) instead of implying both succeeded, and links to any created
quality issue for Operations Manager.
This commit is contained in:
NuklearRabbit
2026-08-02 05:33:12 +02:00
parent 62ac9f825c
commit f5212959b4
8 changed files with 435 additions and 77 deletions
+123 -50
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from sqlalchemy import select
@@ -25,12 +26,120 @@ def _next_public_ref(db: Session) -> str:
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
def _derive_vehicle_status(body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int) -> str:
if body.damage_reported or body.technical_warning:
return "blocked"
def _derive_vehicle_status_with_reason(
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
) -> tuple[str, str]:
if body.damage_reported and body.technical_warning:
return "blocked", "Damage and a technical warning were both reported on return."
if body.damage_reported:
return "blocked", "Damage was reported on return."
if body.technical_warning:
return "blocked", "A technical warning was reported on return."
if new_odometer >= vehicle.next_service_km:
return "maintenance"
return "cleaning"
return (
"maintenance",
f"Odometer reached the {vehicle.next_service_km:,} km service threshold.",
)
return "cleaning", "No damage, technical warning or service threshold; routed to cleaning."
@dataclass
class ReturnEvaluation:
canonical_odometer_km: int
submitted_odometer_km: int
odometer_regression: bool
resulting_odometer_km: int
resulting_vehicle_status: str
status_reason: str
would_create_quality_issue: bool
attention_reasons: list[str]
next_booking_risk: dict | None
def evaluate_return(
db: Session, booking: Booking, vehicle: Vehicle, body: RegisterReturnRequest, *, now: datetime
) -> ReturnEvaluation:
"""Pure evaluation of what a return would do. No writes; safe to call from a
non-mutating preview endpoint. `register_vehicle_return` uses the same function so
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(
body, vehicle, resulting_odometer_km
)
attention_reasons = []
if body.damage_reported:
attention_reasons.append("damage_reported")
if body.technical_warning:
attention_reasons.append("technical_warning")
if odometer_regression:
attention_reasons.append("odometer_regression")
next_booking = db.scalar(
select(Booking)
.where(
Booking.vehicle_id == vehicle.id,
Booking.status == "reserved",
Booking.starts_at > now,
)
.order_by(Booking.starts_at.asc())
)
next_booking_risk = None
if next_booking is not None:
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
next_booking_risk = {
"booking_ref": next_booking.public_ref,
"starts_at": next_booking.starts_at.isoformat(),
"at_risk": resulting_status != "cleaning" or hours_until < 4,
}
return ReturnEvaluation(
canonical_odometer_km=vehicle.odometer_km,
submitted_odometer_km=body.end_odometer_km,
odometer_regression=odometer_regression,
resulting_odometer_km=resulting_odometer_km,
resulting_vehicle_status=resulting_status,
status_reason=status_reason,
would_create_quality_issue=odometer_regression,
attention_reasons=attention_reasons,
next_booking_risk=next_booking_risk,
)
def _load_active_booking_and_vehicle(
db: Session, booking_ref: str, *, lock: bool
) -> tuple[Booking, Vehicle]:
stmt = select(Booking).where(Booking.public_ref == booking_ref)
if lock:
stmt = stmt.with_for_update()
booking = db.scalar(stmt)
if booking is None:
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
vehicle_stmt = select(Vehicle).where(Vehicle.id == booking.vehicle_id)
if lock:
vehicle_stmt = vehicle_stmt.with_for_update()
vehicle = db.scalar(vehicle_stmt)
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
)
return booking, vehicle
def preview_vehicle_return(
db: Session, booking_ref: str, body: RegisterReturnRequest
) -> tuple[Booking, Vehicle, ReturnEvaluation]:
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=False)
if booking.status != "active":
raise AppError(
"INVALID_BOOKING_STATE",
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
status_code=409,
)
evaluation = evaluate_return(db, booking, vehicle, body, now=datetime.now(UTC))
return booking, vehicle, evaluation
def register_vehicle_return(
@@ -53,14 +162,7 @@ def register_vehicle_return(
)
return existing.response_status, existing.response_body
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
if booking is None:
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
)
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
# Re-check after acquiring the row lock: a concurrent identical-key request may have
# just committed while we were waiting.
@@ -79,6 +181,7 @@ def register_vehicle_return(
now = datetime.now(UTC)
correlation_id = uuid.uuid4()
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
inspection = Inspection(
public_ref=_next_public_ref(db),
@@ -104,13 +207,8 @@ def register_vehicle_return(
booking.status = "returned"
booking.end_odometer_km = body.end_odometer_km
odometer_regression = body.end_odometer_km < vehicle.odometer_km
quality_issue_ref: str | None = None
canonical_odometer = vehicle.odometer_km
if not odometer_regression:
canonical_odometer = body.end_odometer_km
vehicle.odometer_km = canonical_odometer
else:
if evaluation.odometer_regression:
issue = DataQualityIssue(
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
rule_type="odometer_regression",
@@ -121,7 +219,7 @@ def register_vehicle_return(
evidence_json={
"summary": (
f"Return submitted {body.end_odometer_km} km, below canonical "
f"{vehicle.odometer_km} km."
f"{evaluation.canonical_odometer_km} km."
),
"entity_ref": vehicle.public_ref,
"related_refs": [booking.public_ref, inspection.public_ref],
@@ -133,7 +231,8 @@ def register_vehicle_return(
db.flush()
quality_issue_ref = issue.public_ref
resulting_status = _derive_vehicle_status(body, vehicle, canonical_odometer)
resulting_status = evaluation.resulting_vehicle_status
vehicle.odometer_km = evaluation.resulting_odometer_km
vehicle.operational_status = resulting_status
vehicle.version += 1
@@ -164,14 +263,6 @@ def register_vehicle_return(
},
)
attention_reasons = []
if body.damage_reported:
attention_reasons.append("damage_reported")
if body.technical_warning:
attention_reasons.append("technical_warning")
if odometer_regression:
attention_reasons.append("odometer_regression")
event = OutboxEvent(
event_id=uuid.uuid4(),
event_type="vehicle.returned.v1",
@@ -189,7 +280,7 @@ def register_vehicle_return(
"vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status,
"attention_reasons": attention_reasons,
"attention_reasons": evaluation.attention_reasons,
},
"aggregate_ref": booking.public_ref,
},
@@ -199,33 +290,15 @@ def register_vehicle_return(
)
db.add(event)
next_booking = db.scalar(
select(Booking)
.where(
Booking.vehicle_id == vehicle.id,
Booking.status == "reserved",
Booking.starts_at > now,
)
.order_by(Booking.starts_at.asc())
)
next_booking_risk = None
if next_booking is not None:
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
next_booking_risk = {
"booking_ref": next_booking.public_ref,
"starts_at": next_booking.starts_at.isoformat(),
"at_risk": resulting_status != "cleaning" or hours_until < 4,
}
response_body = {
"booking_ref": booking.public_ref,
"vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status,
"odometer_regression": odometer_regression,
"odometer_regression": evaluation.odometer_regression,
"quality_issue_ref": quality_issue_ref,
"workflow_event_id": str(event.event_id),
"next_booking_risk": next_booking_risk,
"next_booking_risk": evaluation.next_booking_risk,
}
db.add(