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.
114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
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,
|
|
NextBookingRisk,
|
|
RegisterReturnRequest,
|
|
ReturnPreviewResult,
|
|
)
|
|
from app.services.returns import preview_vehicle_return, register_vehicle_return
|
|
|
|
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
|
|
|
|
|
def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut:
|
|
return BookingOut(
|
|
public_ref=booking.public_ref,
|
|
customer_ref=customer.public_ref,
|
|
vehicle_ref=vehicle.public_ref,
|
|
starts_at=booking.starts_at,
|
|
ends_at=booking.ends_at,
|
|
status=booking.status,
|
|
start_odometer_km=booking.start_odometer_km,
|
|
end_odometer_km=booking.end_odometer_km,
|
|
requirements_complete=booking.requirements_complete,
|
|
customer_name=f"{customer.first_name} {customer.last_name}",
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[BookingOut])
|
|
def list_bookings(
|
|
status: str | None = Query(default=None),
|
|
vehicle_ref: str | None = Query(default=None),
|
|
db: Session = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
) -> list[BookingOut]:
|
|
stmt = select(Booking).order_by(Booking.starts_at.desc())
|
|
if status:
|
|
stmt = stmt.where(Booking.status == status)
|
|
if vehicle_ref:
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
|
if vehicle is None:
|
|
return []
|
|
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
|
bookings = db.scalars(stmt).all()
|
|
customers = {c.id: c for c in db.scalars(select(Customer)).all()}
|
|
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
|
return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
|
|
|
|
|
|
@router.get("/{public_ref}", response_model=BookingOut)
|
|
def get_booking(
|
|
public_ref: str,
|
|
db: Session = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
) -> BookingOut:
|
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref))
|
|
if booking is None:
|
|
raise HTTPException(status_code=404, detail="Booking not found")
|
|
customer = db.get(Customer, booking.customer_id)
|
|
vehicle = db.get(Vehicle, booking.vehicle_id)
|
|
if customer is None or vehicle is None:
|
|
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
|
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,
|
|
body: RegisterReturnRequest,
|
|
response: Response,
|
|
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
|
|
db: Session = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> dict:
|
|
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
|
|
response.status_code = status_code
|
|
return result
|