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
+147
View File
@@ -1,11 +1,16 @@
import threading
import uuid
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import SessionLocal
from app.main import app
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.outbox import OutboxEvent
from app.models.vehicle import Vehicle
@@ -39,6 +44,148 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db.close()
def _set_next_service_km(vehicle_ref: str, threshold: int) -> None:
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
vehicle.next_service_km = threshold
db.commit()
finally:
db.close()
def _add_reserved_booking(vehicle_ref: str, *, hours_from_now: float) -> str:
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
customer = db.scalar(select(Customer))
starts_at = datetime.now(UTC) + timedelta(hours=hours_from_now)
public_ref = f"BK-TEST-{uuid.uuid4().hex[:8].upper()}"
booking = Booking(
public_ref=public_ref,
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=starts_at,
ends_at=starts_at + timedelta(days=2),
status="reserved",
requirements_complete=True,
)
db.add(booking)
db.commit()
return public_ref
finally:
db.close()
def _counts() -> tuple[int, int]:
db = SessionLocal()
try:
return (
len(db.scalars(select(AuditEvent)).all()),
len(db.scalars(select(OutboxEvent)).all()),
)
finally:
db.close()
def test_preview_performs_no_writes_and_matches_commit(ops_client):
booking_ref = _activate_booking("MO-006", start_odometer_km=30000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-006").json()
new_reading = vehicle_before["odometer_km"] + 25
body = _return_body(end_odometer_km=new_reading)
audit_before, outbox_before = _counts()
preview = ops_client.post(f"/api/v1/bookings/{booking_ref}/return-preview", json=body)
assert preview.status_code == 200
preview_body = preview.json()
audit_after, outbox_after = _counts()
assert (audit_after, outbox_after) == (audit_before, outbox_before)
booking_mid = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
assert booking_mid["status"] == "active" # preview did not mutate the booking
vehicle_mid = ops_client.get("/api/v1/vehicles/MO-006").json()
assert vehicle_mid["odometer_km"] == vehicle_before["odometer_km"]
assert preview_body["odometer_regression"] is False
assert preview_body["resulting_odometer_km"] == new_reading
assert preview_body["canonical_odometer_km"] == vehicle_before["odometer_km"]
commit = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=body,
headers={"Idempotency-Key": "test-preview-matches-commit-001"},
)
assert commit.status_code == 201
commit_body = commit.json()
assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
def test_preview_detects_odometer_regression(ops_client):
booking_ref = _activate_booking("MO-007", start_odometer_km=15000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-007").json()
low_reading = vehicle_before["odometer_km"] - 100
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=low_reading),
)
assert preview.status_code == 200
body = preview.json()
assert body["odometer_regression"] is True
assert body["would_create_quality_issue"] is True
assert "odometer_regression" in body["attention_reasons"]
assert body["resulting_odometer_km"] == vehicle_before["odometer_km"]
vehicle_after = ops_client.get("/api/v1/vehicles/MO-007").json()
assert vehicle_after["odometer_km"] == vehicle_before["odometer_km"]
def test_preview_detects_service_due(ops_client):
booking_ref = _activate_booking("MO-009", start_odometer_km=18000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-009").json()
_set_next_service_km("MO-009", vehicle_before["odometer_km"] + 50)
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=vehicle_before["odometer_km"] + 100),
)
assert preview.status_code == 200
body = preview.json()
assert body["resulting_vehicle_status"] == "maintenance"
assert "service threshold" in body["status_reason"]
def test_preview_detects_next_booking_risk(ops_client):
booking_ref = _activate_booking("MO-011", start_odometer_km=19000)
_add_reserved_booking("MO-011", hours_from_now=2)
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=19500),
)
assert preview.status_code == 200
risk = preview.json()["next_booking_risk"]
assert risk is not None
assert risk["at_risk"] is True # less than 4 hours away
def test_preview_requires_active_booking(ops_client):
booking_ref = _activate_booking("MO-014", start_odometer_km=21000)
ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=21500),
headers={"Idempotency-Key": "test-preview-requires-active-001"},
)
preview = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return-preview",
json=_return_body(end_odometer_km=22000),
)
assert preview.status_code == 409
assert preview.json()["error"]["code"] == "INVALID_BOOKING_STATE"
def test_register_return_success_updates_canonical_odometer(ops_client):
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()