From f5212959b481d4df4514c715761020c5b3b5c9b3 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:33:12 +0200 Subject: [PATCH] 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. --- backend/app/api/routers/bookings.py | 37 +++++- backend/app/schemas.py | 18 +++ backend/app/services/returns.py | 173 ++++++++++++++++++------- backend/tests/test_return.py | 147 +++++++++++++++++++++ frontend/e2e/demo.spec.ts | 2 +- frontend/e2e/ui-redesign.spec.ts | 14 +- frontend/src/api/types.ts | 18 +++ frontend/src/components/ReturnForm.tsx | 103 ++++++++++++--- 8 files changed, 435 insertions(+), 77 deletions(-) diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py index 8d3e6fb..1dae99f 100644 --- a/backend/app/api/routers/bookings.py +++ b/backend/app/api/routers/bookings.py @@ -8,8 +8,14 @@ from app.api.deps import get_current_user, get_db from app.models.booking import Booking from app.models.customer import Customer from app.models.vehicle import Vehicle -from app.schemas import BookingOut, CurrentUser, RegisterReturnRequest -from app.services.returns import register_vehicle_return +from app.schemas import ( + BookingOut, + CurrentUser, + NextBookingRisk, + RegisterReturnRequest, + ReturnPreviewResult, +) +from app.services.returns import preview_vehicle_return, register_vehicle_return router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) @@ -66,6 +72,33 @@ def get_booking( return _to_out(booking, customer, vehicle) +@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult) +def preview_return( + public_ref: str, + body: RegisterReturnRequest, + db: Session = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +) -> ReturnPreviewResult: + booking, vehicle, evaluation = preview_vehicle_return(db, public_ref, body) + return ReturnPreviewResult( + booking_ref=booking.public_ref, + vehicle_ref=vehicle.public_ref, + canonical_odometer_km=evaluation.canonical_odometer_km, + submitted_odometer_km=evaluation.submitted_odometer_km, + odometer_regression=evaluation.odometer_regression, + resulting_odometer_km=evaluation.resulting_odometer_km, + resulting_vehicle_status=evaluation.resulting_vehicle_status, + status_reason=evaluation.status_reason, + would_create_quality_issue=evaluation.would_create_quality_issue, + attention_reasons=evaluation.attention_reasons, + next_booking_risk=( + NextBookingRisk(**evaluation.next_booking_risk) + if evaluation.next_booking_risk is not None + else None + ), + ) + + @router.post("/{public_ref}/return") def register_return( public_ref: str, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 29940d5..3f4204a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -74,6 +74,20 @@ class RegisterReturnResult(BaseModel): next_booking_risk: NextBookingRisk | None +class ReturnPreviewResult(BaseModel): + booking_ref: str + vehicle_ref: str + 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: NextBookingRisk | None + + class InspectionOut(BaseModel): public_ref: str booking_ref: str @@ -216,6 +230,10 @@ class AuditEventOut(BaseModel): action: str entity_type: str entity_id: str | None + entity_ref: str | None = None + entity_link: str | None = None correlation_id: str occurred_at: datetime + before: dict[str, Any] | None = None + after: dict[str, Any] | None = None metadata: dict[str, Any] | None = None diff --git a/backend/app/services/returns.py b/backend/app/services/returns.py index 2f2a1e7..0f2bcf8 100644 --- a/backend/app/services/returns.py +++ b/backend/app/services/returns.py @@ -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( diff --git a/backend/tests/test_return.py b/backend/tests/test_return.py index fb3d219..cd700a8 100644 --- a/backend/tests/test_return.py +++ b/backend/tests/test_return.py @@ -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() diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index d9b0899..9b926f7 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -57,7 +57,7 @@ test("five-minute demo script end to end", async ({ page, request }) => { await test.step("5. verify quality issue and queued automation event", async () => { await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible(); - await expect(page.getByText(/Queued \(/)).toBeVisible(); + await expect(page.getByText(/Queued for delivery \(/)).toBeVisible(); }); await test.step("6. resolve the duplicate customer scenario (S2)", async () => { diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts index e13136b..b9c889b 100644 --- a/frontend/e2e/ui-redesign.spec.ts +++ b/frontend/e2e/ui-redesign.spec.ts @@ -35,14 +35,20 @@ test("return review separates capture from irreversible commit", async ({ page } await page.getByLabel("End odometer (km)").fill("60000"); await page.getByLabel("Fuel level (%)").fill("65"); - let returnRequests = 0; + let commitRequests = 0; + let previewRequests = 0; page.on("request", (request) => { - if (request.url().includes("/return") && request.method() === "POST") returnRequests += 1; + if (request.method() !== "POST") return; + if (request.url().endsWith("/return-preview")) previewRequests += 1; + else if (request.url().endsWith("/return")) commitRequests += 1; }); await page.getByRole("button", { name: "Review return" }).click(); - expect(returnRequests).toBe(0); - await expect(page.getByText("Expected fleet state")).toBeVisible(); + await expect(page.getByText(/Expected fleet state/)).toBeVisible(); + expect(commitRequests).toBe(0); + // The review step is server-evaluated (not client-guessed), so exactly one non-mutating + // preview call is expected before any commit. + expect(previewRequests).toBe(1); await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible(); await page.getByRole("button", { name: "Edit details" }).click(); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 2d74b64..0bf816f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -145,6 +145,20 @@ export interface RegisterReturnResult { next_booking_risk: NextBookingRisk | null; } +export interface ReturnPreviewResult { + booking_ref: string; + vehicle_ref: string; + canonical_odometer_km: number; + submitted_odometer_km: number; + odometer_regression: boolean; + resulting_odometer_km: number; + resulting_vehicle_status: string; + status_reason: string; + would_create_quality_issue: boolean; + attention_reasons: string[]; + next_booking_risk: NextBookingRisk | null; +} + export interface EntitySnapshot { public_ref: string; [key: string]: unknown; @@ -200,7 +214,11 @@ export interface AuditEvent { action: string; entity_type: string; entity_id: string | null; + entity_ref: string | null; + entity_link: string | null; correlation_id: string; occurred_at: string; + before: Record | null; + after: Record | null; metadata: Record | null; } diff --git a/frontend/src/components/ReturnForm.tsx b/frontend/src/components/ReturnForm.tsx index cd776a8..1ef68bc 100644 --- a/frontend/src/components/ReturnForm.tsx +++ b/frontend/src/components/ReturnForm.tsx @@ -1,7 +1,8 @@ import { useState, type FormEvent } from "react"; import { Link } from "react-router-dom"; import { api, ApiError } from "../api/client"; -import type { RegisterReturnRequest, RegisterReturnResult } from "../api/types"; +import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types"; +import { useAuth } from "../context/AuthContext"; import { Icon } from "./Icons"; import { StatusBadge } from "./Badge"; @@ -12,6 +13,8 @@ function newIdempotencyKey(): string { } export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) { + const { user } = useAuth(); + const canSeeQualityIssue = user?.role === "operations_manager"; return (

Committed locally

Return registered

@@ -20,9 +23,25 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
Resulting vehicle status
Quality issue
-
{result.quality_issue_ref ?? "None created"}
+
+ {result.quality_issue_ref ? ( + canSeeQualityIssue ? ( + {result.quality_issue_ref} + ) : ( + result.quality_issue_ref + ) + ) : ( + "None created" + )} +
+
+
+
Automation event
+
+ Queued for delivery ({result.workflow_event_id.slice(0, 8)}) — local commit succeeded; + n8n delivery is asynchronous and not yet confirmed. +
-
Automation event
Queued ({result.workflow_event_id.slice(0, 8)})
Next booking risk
@@ -60,30 +79,50 @@ export function ReturnForm({ const [technicalWarning, setTechnicalWarning] = useState(false); const [notes, setNotes] = useState(""); const [submitting, setSubmitting] = useState(false); + const [previewing, setPreviewing] = useState(false); const [error, setError] = useState(null); const [idempotencyKey] = useState(newIdempotencyKey); const [step, setStep] = useState<"capture" | "review">("capture"); + const [preview, setPreview] = useState(null); + + function currentBody(): RegisterReturnRequest { + return { + end_odometer_km: Number(odometer), + fuel_level_percent: Number(fuel), + cleanliness_ok: cleanlinessOk, + damage_reported: damageReported, + technical_warning: technicalWarning, + notes: notes || undefined, + }; + } async function handleSubmit(e: FormEvent) { e.preventDefault(); if (step === "capture") { - setStep("review"); + setError(null); + setPreviewing(true); + try { + const evaluated = await api.post( + `/api/v1/bookings/${bookingRef}/return-preview`, + currentBody(), + ); + setPreview(evaluated); + setStep("review"); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "Could not evaluate this return. Please try again.", + ); + } finally { + setPreviewing(false); + } return; } setError(null); setSubmitting(true); try { - const body: RegisterReturnRequest = { - end_odometer_km: Number(odometer), - fuel_level_percent: Number(fuel), - cleanliness_ok: cleanlinessOk, - damage_reported: damageReported, - technical_warning: technicalWarning, - notes: notes || undefined, - }; const registered = await api.post( `/api/v1/bookings/${bookingRef}/return`, - body, + currentBody(), { "Idempotency-Key": idempotencyKey }, ); onRegistered(registered); @@ -101,7 +140,7 @@ export function ReturnForm({ return (
1 Capture2 Review3 Result
-

Booking {bookingRef}

{step === "capture" ? "Register vehicle return" : "Review return impact"}

{step === "capture" ? "Record the hand-back condition. Operational consequences are calculated on commit." : "Confirm the inspection facts before they update fleet state and queue automation."}

+

Booking {bookingRef}

{step === "capture" ? "Register vehicle return" : "Review return impact"}

{step === "capture" ? "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed." : "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation."}

{error &&

{error}

} {step === "capture" ?
@@ -158,19 +197,43 @@ export function ReturnForm({