M12: complete daily operations cycle

This commit is contained in:
NuklearRabbit
2026-08-10 03:15:46 +02:00
parent 4a3c3bd0a9
commit 15bdbe40ac
32 changed files with 802 additions and 18 deletions
+98 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import func, or_, select
@@ -10,12 +10,15 @@ 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,
CreateBookingRequest,
CurrentUser,
NextBookingRisk,
@@ -154,6 +157,100 @@ def create_booking(
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,
+100
View File
@@ -0,0 +1,100 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.security import hash_password
from app.models.user import User
from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/users", tags=["users"])
def _to_out(user: User) -> UserOut:
return UserOut(
public_ref=user.public_ref,
email=user.email,
display_name=user.display_name,
role=user.role, # type: ignore[arg-type]
active=user.active,
)
@router.get("", response_model=list[UserOut])
def list_users(
db: Session = Depends(get_db),
_manager: CurrentUser = Depends(require_operations_manager),
) -> list[UserOut]:
return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()]
@router.post("", response_model=UserOut, status_code=201)
def create_user(
body: CreateUserRequest,
db: Session = Depends(get_db),
manager: CurrentUser = Depends(require_operations_manager),
) -> UserOut:
email = body.email.strip().lower()
if db.scalar(select(User.id).where(User.email == email)) is not None:
raise HTTPException(status_code=409, detail="A user with this email already exists")
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=hash_password(body.password),
display_name=body.display_name.strip(),
role=body.role,
active=True,
)
db.add(user)
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=manager.display_name,
action="user_created",
entity_type="user",
entity_id=user.id,
after={"public_ref": user.public_ref, "role": user.role, "active": user.active},
)
db.commit()
return _to_out(user)
@router.patch("/{public_ref}", response_model=UserOut)
def update_user(
public_ref: str,
body: UpdateUserRequest,
db: Session = Depends(get_db),
manager: CurrentUser = Depends(require_operations_manager),
) -> UserOut:
user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update())
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.public_ref == manager.public_ref and body.active is False:
raise HTTPException(status_code=409, detail="You cannot deactivate your own account")
if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"):
raise HTTPException(status_code=409, detail="You cannot remove your own manager role")
before = {"display_name": user.display_name, "role": user.role, "active": user.active}
if body.display_name is not None:
user.display_name = body.display_name.strip()
if body.role is not None:
user.role = body.role
if body.active is not None:
user.active = body.active
if body.password is not None:
user.password_hash = hash_password(body.password)
record_audit_event(
db,
actor_type="user",
actor_label=manager.display_name,
action="user_updated",
entity_type="user",
entity_id=user.id,
before=before,
after={"display_name": user.display_name, "role": user.role, "active": user.active},
)
db.commit()
return _to_out(user)
+107 -1
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.api.deps import get_current_user, get_db, require_operations_manager
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
@@ -13,14 +15,17 @@ from app.models.maintenance import MaintenanceRecord
from app.models.vehicle import Vehicle
from app.schemas import (
BookingSummaryOut,
CreateMaintenanceRequest,
CurrentUser,
DataQualityIssueOut,
InspectionOut,
MaintenanceOut,
ReleaseVehicleRequest,
VehicleDetailOut,
VehicleOut,
VehiclePageOut,
)
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -192,3 +197,104 @@ def get_vehicle(
for q in issues
],
)
@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201)
def create_maintenance_record(
public_ref: str,
body: CreateMaintenanceRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> MaintenanceOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
record = MaintenanceRecord(
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
vehicle_id=vehicle.id,
occurred_at=body.occurred_at,
odometer_km=body.odometer_km,
category=body.category,
summary=body.summary.strip(),
)
db.add(record)
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
if body.next_service_km is not None:
if body.next_service_km < vehicle.odometer_km:
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
vehicle.next_service_km = body.next_service_km
if body.mark_maintenance:
vehicle.operational_status = "maintenance"
vehicle.version += 1
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="maintenance_record_created",
entity_type="vehicle",
entity_id=vehicle.id,
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
)
db.commit()
return MaintenanceOut(
public_ref=record.public_ref,
occurred_at=record.occurred_at,
odometer_km=record.odometer_km,
category=record.category,
summary=record.summary,
)
@router.post("/{public_ref}/release", response_model=VehicleOut)
def release_vehicle(
public_ref: str,
body: ReleaseVehicleRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> VehicleOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
raise HTTPException(status_code=409, detail="Vehicle does not require release")
active_booking = db.scalar(
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
)
open_high_issue = db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
)
)
if active_booking is not None or open_high_issue is not None:
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
before = {"status": vehicle.operational_status}
vehicle.operational_status = "available"
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="vehicle_released",
entity_type="vehicle",
entity_id=vehicle.id,
before=before,
after={"status": "available", "reason": body.reason.strip()},
)
db.commit()
return VehicleOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
model_year=vehicle.model_year,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
active=vehicle.active,
attention=False,
)
+2
View File
@@ -18,6 +18,7 @@ from app.api.routers import (
knowledge,
mcp_integrations,
search,
users,
vehicles,
workflows,
)
@@ -100,3 +101,4 @@ app.include_router(knowledge.router)
app.include_router(mcp_integrations.router)
app.include_router(search.router)
app.include_router(integration_status.router)
app.include_router(users.router)
+54
View File
@@ -17,6 +17,28 @@ class PasswordLoginRequest(BaseModel):
password: str = Field(min_length=8, max_length=256)
class UserOut(BaseModel):
public_ref: str
email: str | None
display_name: str
role: Role
active: bool
class CreateUserRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
display_name: str = Field(min_length=2, max_length=120)
role: Role
password: str = Field(min_length=8, max_length=256)
class UpdateUserRequest(BaseModel):
display_name: str | None = Field(default=None, min_length=2, max_length=120)
role: Role | None = None
active: bool | None = None
password: str | None = Field(default=None, min_length=8, max_length=256)
class CurrentUser(BaseModel):
public_ref: str
display_name: str
@@ -88,6 +110,25 @@ class CancelBookingRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
class CheckoutBookingRequest(BaseModel):
start_odometer_km: Annotated[int, Field(ge=0)]
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
cleanliness_ok: bool
damage_reported: bool = False
technical_warning: bool = False
notes: str | None = Field(default=None, max_length=2000)
class CheckoutBookingResult(BaseModel):
booking_ref: str
vehicle_ref: str
inspection_ref: str
booking_status: str
resulting_vehicle_status: str
activated: bool
attention_reasons: list[str]
class BookingPageOut(BaseModel):
items: list[BookingOut]
page: int
@@ -158,6 +199,19 @@ class MaintenanceOut(BaseModel):
summary: str
class CreateMaintenanceRequest(BaseModel):
occurred_at: datetime
odometer_km: Annotated[int, Field(ge=0)]
category: Literal["periodic_service", "repair", "inspection", "tyres", "other"]
summary: str = Field(min_length=3, max_length=2000)
next_service_km: Annotated[int | None, Field(default=None, ge=0)]
mark_maintenance: bool = True
class ReleaseVehicleRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
class DataQualityIssueOut(BaseModel):
public_ref: str
rule_type: str
+27
View File
@@ -133,6 +133,33 @@ def test_concurrent_bookings_only_reserve_vehicle_once():
assert results.count(409) == 1
def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
vehicle_option = next(item for item in available if item["operational_status"] == "available")
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
booking = ops_client.post(
"/api/v1/bookings",
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
).json()
response = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
"start_odometer_km": vehicle["odometer_km"],
"fuel_level_percent": 95,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
)
assert response.status_code == 200
assert response.json()["activated"] is True
assert response.json()["booking_status"] == "active"
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert updated_vehicle["operational_status"] == "rented"
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200
+25
View File
@@ -0,0 +1,25 @@
def test_manager_can_create_and_update_user(ops_client):
created = ops_client.post(
"/api/v1/users",
json={
"email": "planner@example.test",
"display_name": "Fleet Planner",
"role": "rental_employee",
"password": "a-secure-demo-password",
},
)
assert created.status_code == 201
public_ref = created.json()["public_ref"]
assert created.json()["active"] is True
updated = ops_client.patch(
f"/api/v1/users/{public_ref}",
json={"display_name": "Senior Fleet Planner", "active": False},
)
assert updated.status_code == 200
assert updated.json()["display_name"] == "Senior Fleet Planner"
assert updated.json()["active"] is False
assert any(user["public_ref"] == public_ref for user in ops_client.get("/api/v1/users").json())
def test_employee_cannot_manage_users(employee_client):
assert employee_client.get("/api/v1/users").status_code == 403
+41
View File
@@ -37,3 +37,44 @@ def test_vehicle_detail_includes_related_records(ops_client):
def test_vehicle_detail_404_for_unknown_ref(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-999")
assert response.status_code == 404
def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[0]
response = ops_client.post(
f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance",
json={
"occurred_at": "2051-01-15T09:00:00Z",
"odometer_km": vehicle["odometer_km"] + 100,
"category": "periodic_service",
"summary": "Periodic service completed and safety checks passed.",
"next_service_km": vehicle["odometer_km"] + 20100,
"mark_maintenance": True,
},
)
assert response.status_code == 201
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert detail["operational_status"] == "maintenance"
assert any(
item["public_ref"] == response.json()["public_ref"]
for item in detail["maintenance"]
)
released = ops_client.post(
f"/api/v1/vehicles/{vehicle['public_ref']}/release",
json={"reason": "Service completed and vehicle inspected"},
)
assert released.status_code == 200
assert released.json()["operational_status"] == "available"
def test_employee_cannot_record_maintenance(employee_client):
response = employee_client.post(
"/api/v1/vehicles/MO-001/maintenance",
json={
"occurred_at": "2051-01-15T09:00:00Z",
"odometer_km": 100,
"category": "repair",
"summary": "Unauthorised attempt",
},
)
assert response.status_code == 403