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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user