M9: create validated internal bookings
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -11,12 +13,14 @@ from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
BookingOut,
|
||||
BookingPageOut,
|
||||
CreateBookingRequest,
|
||||
CurrentUser,
|
||||
NextBookingRisk,
|
||||
RegisterReturnRequest,
|
||||
RegisterReturnResult,
|
||||
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"])
|
||||
@@ -89,6 +93,60 @@ def list_bookings(
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref))
|
||||
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.get("/{public_ref}", response_model=BookingOut)
|
||||
def get_booking(
|
||||
public_ref: str,
|
||||
|
||||
@@ -61,6 +61,14 @@ class BookingOut(BookingSummaryOut):
|
||||
customer_name: str
|
||||
|
||||
|
||||
class CreateBookingRequest(BaseModel):
|
||||
customer_ref: str = Field(min_length=3, max_length=20)
|
||||
vehicle_ref: str = Field(min_length=3, max_length=20)
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
requirements_complete: bool = True
|
||||
|
||||
|
||||
class BookingPageOut(BaseModel):
|
||||
items: list[BookingOut]
|
||||
page: int
|
||||
|
||||
@@ -20,6 +20,28 @@ def test_list_bookings_supports_bounded_search_pages(ops_client):
|
||||
assert len(body["items"]) == 25
|
||||
|
||||
|
||||
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
|
||||
conflict = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001", "vehicle_ref": "MO-024",
|
||||
"starts_at": "2026-08-10T10:00:00Z", "ends_at": "2026-08-10T12:00:00Z",
|
||||
},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
created = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001", "vehicle_ref": "MO-001",
|
||||
"starts_at": "2026-09-01T10:00:00Z", "ends_at": "2026-09-02T12:00:00Z",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert body["status"] == "reserved"
|
||||
assert body["vehicle_ref"] == "MO-001"
|
||||
|
||||
|
||||
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