M11: implement operational booking lifecycle

This commit is contained in:
NuklearRabbit
2026-08-10 03:06:30 +02:00
parent 3f13912739
commit 4a3c3bd0a9
15 changed files with 516 additions and 7 deletions
+92 -1
View File
@@ -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,
+41
View File
@@ -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
]
+2
View File
@@ -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)
+19
View File
@@ -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
+83
View File
@@ -1,3 +1,10 @@
import threading
from fastapi.testclient import TestClient
from app.main import app
def test_list_bookings_filters_by_vehicle(ops_client):
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"})
assert response.status_code == 200
@@ -50,6 +57,82 @@ def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
assert body["vehicle_ref"] == available_vehicle
def test_customer_search_returns_canonical_customers(ops_client):
response = ops_client.get("/api/v1/customers", params={"query": "CUS-"})
assert response.status_code == 200
assert response.json()
assert all(item["public_ref"].startswith("CUS-") for item in response.json())
def test_booking_availability_excludes_overlapping_vehicle(ops_client):
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
response = ops_client.get(
"/api/v1/bookings/availability",
params={"starts_at": existing["starts_at"], "ends_at": existing["ends_at"]},
)
assert response.status_code == 200
assert existing["vehicle_ref"] not in {item["public_ref"] for item in response.json()}
def test_reserved_booking_can_be_cancelled_once(ops_client):
window = {"starts_at": "2032-09-01T10:00:00Z", "ends_at": "2032-09-02T12:00:00Z"}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
assert available
create_response = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": available[0]["public_ref"],
**window,
},
)
assert create_response.status_code == 201
created = create_response.json()
response = ops_client.post(
f"/api/v1/bookings/{created['public_ref']}/cancel",
json={"reason": "Customer changed plans"},
)
assert response.status_code == 200
assert response.json()["status"] == "cancelled"
repeated = ops_client.post(
f"/api/v1/bookings/{created['public_ref']}/cancel",
json={"reason": "Customer changed plans"},
)
assert repeated.status_code == 409
def test_concurrent_bookings_only_reserve_vehicle_once():
results: list[int] = []
seed_client = TestClient(app)
seed_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
window = {"starts_at": "2040-09-01T10:00:00Z", "ends_at": "2040-09-02T12:00:00Z"}
available = seed_client.get("/api/v1/bookings/availability", params=window).json()
assert available
vehicle_ref = available[0]["public_ref"]
def submit() -> None:
client = TestClient(app)
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
response = client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle_ref,
**window,
},
)
results.append(response.status_code)
threads = [threading.Thread(target=submit) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert results.count(201) == 1
assert results.count(409) == 1
def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200