M11: implement operational booking lifecycle
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
||||
from sqlalchemy import func, or_, select
|
||||
@@ -11,8 +12,10 @@ from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
AvailableVehicleOut,
|
||||
BookingOut,
|
||||
BookingPageOut,
|
||||
CancelBookingRequest,
|
||||
CreateBookingRequest,
|
||||
CurrentUser,
|
||||
NextBookingRisk,
|
||||
@@ -104,7 +107,11 @@ def create_booking(
|
||||
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")
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref))
|
||||
# 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
|
||||
@@ -147,6 +154,56 @@ def create_booking(
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@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,
|
||||
@@ -163,6 +220,40 @@ def get_booking(
|
||||
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 != "reserved":
|
||||
raise HTTPException(status_code=409, detail="Only a reserved 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,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.customer import Customer
|
||||
from app.schemas import CurrentUser, CustomerOptionOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomerOptionOut])
|
||||
def search_customers(
|
||||
query: str = Query(min_length=2, max_length=100),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[CustomerOptionOut]:
|
||||
term = f"%{query.strip()}%"
|
||||
customers = db.scalars(
|
||||
select(Customer)
|
||||
.where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
or_(
|
||||
Customer.public_ref.ilike(term),
|
||||
Customer.first_name.ilike(term),
|
||||
Customer.last_name.ilike(term),
|
||||
Customer.email.ilike(term),
|
||||
),
|
||||
)
|
||||
.order_by(Customer.last_name, Customer.first_name)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [
|
||||
CustomerOptionOut(
|
||||
public_ref=customer.public_ref,
|
||||
display_name=f"{customer.first_name} {customer.last_name}",
|
||||
email=customer.email,
|
||||
)
|
||||
for customer in customers
|
||||
]
|
||||
@@ -9,6 +9,7 @@ from app.api.routers import (
|
||||
audit,
|
||||
auth,
|
||||
bookings,
|
||||
customers,
|
||||
dashboard,
|
||||
data_quality,
|
||||
demo,
|
||||
@@ -90,6 +91,7 @@ app.include_router(auth.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(vehicles.router)
|
||||
app.include_router(bookings.router)
|
||||
app.include_router(customers.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(data_quality.router)
|
||||
app.include_router(workflows.router)
|
||||
|
||||
@@ -69,6 +69,25 @@ class CreateBookingRequest(BaseModel):
|
||||
requirements_complete: bool = True
|
||||
|
||||
|
||||
class CustomerOptionOut(BaseModel):
|
||||
public_ref: str
|
||||
display_name: str
|
||||
email: str | None
|
||||
|
||||
|
||||
class AvailableVehicleOut(BaseModel):
|
||||
public_ref: str
|
||||
make: str
|
||||
model: str
|
||||
registration_number: str
|
||||
location: str
|
||||
operational_status: str
|
||||
|
||||
|
||||
class CancelBookingRequest(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class BookingPageOut(BaseModel):
|
||||
items: list[BookingOut]
|
||||
page: int
|
||||
|
||||
Reference in New Issue
Block a user