from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Literal from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from sqlalchemy import case, func, or_, 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.inspection import Inspection from app.models.vehicle import Vehicle from app.schemas import ( AvailableVehicleOut, BookingOut, BookingPageOut, CancelBookingRequest, CheckoutBookingRequest, CheckoutBookingResult, CompleteBookingRequirementsRequest, CreateBookingRequest, CurrentUser, NextBookingRisk, RegisterReturnRequest, RegisterReturnResult, RescheduleBookingRequest, ReturnPreviewResult, ) from app.services.audit import record_audit_event 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] | BookingPageOut) def list_bookings( status: str | None = Query(default=None), vehicle_ref: str | None = Query(default=None), query: str | None = Query(default=None, min_length=1, max_length=100), starts_from: datetime | None = Query(default=None), starts_to: datetime | None = Query(default=None), location: str | None = Query(default=None, min_length=1, max_length=120), sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"), page: int | None = Query(default=None, ge=1), page_size: int = Query(default=25, ge=1, le=25), db: Session = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ) -> list[BookingOut] | BookingPageOut: stmt = select(Booking) 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: if page is None: return [] return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1) stmt = stmt.where(Booking.vehicle_id == vehicle.id) if starts_from: stmt = stmt.where(Booking.ends_at >= starts_from) if starts_to: stmt = stmt.where(Booking.starts_at < starts_to) if query or location: stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join( Vehicle, Booking.vehicle_id == Vehicle.id ) if location: stmt = stmt.where(Vehicle.location.ilike(location.strip())) if query: term = f"%{query.strip()}%" stmt = stmt.where( or_( Booking.public_ref.ilike(term), Customer.first_name.ilike(term), Customer.last_name.ilike(term), Vehicle.public_ref.ilike(term), ) ) if sort == "starts_asc": stmt = stmt.order_by(Booking.starts_at.asc()) elif sort == "starts_desc": stmt = stmt.order_by(Booking.starts_at.desc()) else: now = datetime.now(UTC) operational_bucket = case( (Booking.status == "active", 0), (Booking.starts_at >= now, 1), else_=2, ) stmt = stmt.order_by( operational_bucket, case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(), Booking.starts_at.desc(), ) total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 page_number = page or 1 bookings = db.scalars( stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) ).all() customer_ids = {booking.customer_id for booking in bookings} vehicle_ids = {booking.vehicle_id for booking in bookings} customers = { customer.id: customer for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all() } vehicles = { vehicle.id: vehicle for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all() } items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] if page is None: return items total_pages = max(1, (total + page_size - 1) // page_size) return BookingPageOut( items=items, page=min(page_number, total_pages), page_size=page_size, total=total, total_pages=total_pages, ) @router.post("", response_model=BookingOut, status_code=201) def create_booking( body: CreateBookingRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user), ) -> BookingOut: if body.ends_at <= body.starts_at: raise HTTPException(status_code=422, detail="Booking end must be after its start") customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref)) if customer is None or customer.merged_into_customer_id is not None: raise HTTPException(status_code=422, detail="Customer is unavailable for booking") # Serialise booking creation per vehicle. The overlap check must run after # acquiring this lock, otherwise two concurrent requests can both pass it. vehicle = db.scalar( select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update() ) if ( vehicle is None or not vehicle.active or vehicle.operational_status in {"maintenance", "blocked"} ): raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking") overlap = db.scalar( select(Booking.id).where( Booking.vehicle_id == vehicle.id, Booking.status.in_(("reserved", "active")), Booking.starts_at < body.ends_at, Booking.ends_at > body.starts_at, ) ) if overlap is not None: raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking") booking = Booking( public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}", customer_id=customer.id, vehicle_id=vehicle.id, starts_at=body.starts_at, ends_at=body.ends_at, status="reserved", start_odometer_km=None, end_odometer_km=None, requirements_complete=body.requirements_complete, ) db.add(booking) db.flush() record_audit_event( db, actor_type="user", actor_label=user.display_name, action="booking_created", entity_type="booking", entity_id=booking.id, after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref}, ) db.commit() return _to_out(booking, customer, vehicle) @router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult) def checkout_booking( public_ref: str, body: CheckoutBookingRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user), ) -> CheckoutBookingResult: booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) if booking is None: raise HTTPException(status_code=404, detail="Booking not found") if booking.status != "reserved": raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out") if not booking.requirements_complete: raise HTTPException(status_code=409, detail="Booking requirements are incomplete") vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update()) if vehicle is None: raise HTTPException(status_code=500, detail="Booking references a missing vehicle") if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}: raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout") active_conflict = db.scalar( select(Booking.id).where( Booking.vehicle_id == vehicle.id, Booking.status == "active", Booking.id != booking.id, ) ) if active_conflict is not None: raise HTTPException(status_code=409, detail="Vehicle already has an active booking") attention_reasons: list[str] = [] if body.start_odometer_km < vehicle.odometer_km: attention_reasons.append("odometer_regression") if not body.cleanliness_ok: attention_reasons.append("cleanliness") if body.damage_reported: attention_reasons.append("damage") if body.technical_warning: attention_reasons.append("technical_warning") inspection = Inspection( public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}", booking_id=booking.id, vehicle_id=vehicle.id, type="checkout", fuel_level_percent=body.fuel_level_percent, cleanliness_ok=body.cleanliness_ok, damage_reported=body.damage_reported, technical_warning=body.technical_warning, notes=body.notes, odometer_km=body.start_odometer_km, completed_at=datetime.now(UTC), completed_by=user.display_name, ) db.add(inspection) if attention_reasons: booking.status = "blocked" vehicle.operational_status = ( "maintenance" if body.damage_reported or body.technical_warning else "cleaning" ) else: booking.status = "active" booking.start_odometer_km = body.start_odometer_km vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km) vehicle.operational_status = "rented" vehicle.version += 1 db.flush() record_audit_event( db, actor_type="user", actor_label=user.display_name, action="booking_checkout_recorded", entity_type="booking", entity_id=booking.id, after={ "inspection_ref": inspection.public_ref, "booking_status": booking.status, "vehicle_status": vehicle.operational_status, "attention_reasons": attention_reasons, }, ) db.commit() return CheckoutBookingResult( booking_ref=booking.public_ref, vehicle_ref=vehicle.public_ref, inspection_ref=inspection.public_ref, booking_status=booking.status, resulting_vehicle_status=vehicle.operational_status, activated=booking.status == "active", attention_reasons=attention_reasons, ) @router.get("/availability", response_model=list[AvailableVehicleOut]) def list_available_vehicles( starts_at: datetime, ends_at: datetime, query: str | None = Query(default=None, max_length=100), limit: int = Query(default=25, ge=1, le=50), db: Session = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ) -> list[AvailableVehicleOut]: if ends_at <= starts_at: raise HTTPException(status_code=422, detail="Booking end must be after its start") overlapping_vehicle_ids = select(Booking.vehicle_id).where( Booking.status.in_(("reserved", "active")), Booking.starts_at < ends_at, Booking.ends_at > starts_at, ) stmt = ( select(Vehicle) .where( Vehicle.active.is_(True), Vehicle.operational_status.not_in(("maintenance", "blocked")), Vehicle.id.not_in(overlapping_vehicle_ids), ) .order_by(Vehicle.location, Vehicle.public_ref) .limit(limit) ) if query and query.strip(): term = f"%{query.strip()}%" stmt = stmt.where( or_( Vehicle.public_ref.ilike(term), Vehicle.make.ilike(term), Vehicle.model.ilike(term), Vehicle.registration_number.ilike(term), Vehicle.location.ilike(term), ) ) return [ AvailableVehicleOut( public_ref=vehicle.public_ref, make=vehicle.make, model=vehicle.model, registration_number=vehicle.registration_number, location=vehicle.location, operational_status=vehicle.operational_status, ) for vehicle in db.scalars(stmt).all() ] @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}/complete-requirements", response_model=BookingOut) def complete_booking_requirements( public_ref: str, body: CompleteBookingRequirementsRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user), ) -> BookingOut: booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) if booking is None: raise HTTPException(status_code=404, detail="Booking not found") if booking.status != "reserved": raise HTTPException( status_code=409, detail="Requirements can only be confirmed for a reserved booking", ) 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") if not booking.requirements_complete: booking.requirements_complete = True record_audit_event( db, actor_type="user", actor_label=user.display_name, action="booking_requirements_completed", entity_type="booking", entity_id=booking.id, before={"requirements_complete": False}, after={"requirements_complete": True}, metadata={"confirmation": body.confirmation.strip()}, ) db.commit() return _to_out(booking, customer, vehicle) @router.patch("/{public_ref}/schedule", response_model=BookingOut) def reschedule_booking( public_ref: str, body: RescheduleBookingRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user), ) -> BookingOut: if body.ends_at <= body.starts_at: raise HTTPException(status_code=422, detail="Booking end must be after its start") booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) if booking is None: raise HTTPException(status_code=404, detail="Booking not found") if booking.status != "reserved": raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled") vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update()) customer = db.get(Customer, booking.customer_id) if customer is None or vehicle is None: raise HTTPException(status_code=500, detail="Booking references a missing record") overlap = db.scalar( select(Booking.id).where( Booking.vehicle_id == booking.vehicle_id, Booking.id != booking.id, Booking.status.in_(("reserved", "active")), Booking.starts_at < body.ends_at, Booking.ends_at > body.starts_at, ) ) if overlap is not None: raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking") before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()} booking.starts_at = body.starts_at booking.ends_at = body.ends_at record_audit_event( db, actor_type="user", actor_label=user.display_name, action="booking_rescheduled", entity_type="booking", entity_id=booking.id, before=before, after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}, metadata={"reason": body.reason.strip()}, ) db.commit() return _to_out(booking, customer, vehicle) @router.post("/{public_ref}/cancel", response_model=BookingOut) def cancel_booking( public_ref: str, body: CancelBookingRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user), ) -> BookingOut: booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update()) if booking is None: raise HTTPException(status_code=404, detail="Booking not found") if booking.status not in ("reserved", "blocked"): # A booking blocked at checkout (damage, technical warning, ...) has no other exit: # it never became active, so it can neither be returned nor completed. Cancelling # it (audited, with a reason) is the only way to close the file. raise HTTPException( status_code=409, detail="Only a reserved or blocked booking can be cancelled" ) 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") before = {"status": booking.status} booking.status = "cancelled" record_audit_event( db, actor_type="user", actor_label=user.display_name, action="booking_cancelled", entity_type="booking", entity_id=booking.id, before=before, after={"status": booking.status, "reason": body.reason.strip()}, ) db.commit() 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, 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=( NextBookingRisk(**evaluation.next_booking_risk) if evaluation.next_booking_risk is not None else None ), ) @router.post("/{public_ref}/return", response_model=RegisterReturnResult) 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), ) -> RegisterReturnResult: status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user) response.status_code = status_code return RegisterReturnResult(**result)