from __future__ import annotations import uuid from datetime import UTC, datetime 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, require_operations_manager from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.inspection import Inspection 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 from app.services.data_quality import open_odometer_regression_issue router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) def _attention_vehicle_ids(db: Session) -> set: rows = db.scalars( select(DataQualityIssue.entity_id).where( DataQualityIssue.entity_type == "vehicle", DataQualityIssue.status == "open", ) ).all() return set(rows) @router.get("", response_model=list[VehicleOut] | VehiclePageOut) def list_vehicles( status: str | None = Query(default=None), attention_only: bool = Query(default=False), location: str | None = Query(default=None, min_length=1, max_length=120), query: str | None = Query(default=None, min_length=1, max_length=100), page: int | None = Query(default=None, ge=1), page_size: int = Query(default=25, ge=1, le=25), db: Session = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ) -> list[VehicleOut] | VehiclePageOut: stmt = select(Vehicle).order_by(Vehicle.public_ref) if status: stmt = stmt.where(Vehicle.operational_status == status) if location: stmt = stmt.where(Vehicle.location.ilike(location.strip())) if query: term = f"%{query.strip()}%" stmt = stmt.where( or_( Vehicle.public_ref.ilike(term), Vehicle.make.ilike(term), Vehicle.model.ilike(term), Vehicle.location.ilike(term), Vehicle.registration_number.ilike(term), ) ) attention_ids = _attention_vehicle_ids(db) if attention_only: stmt = stmt.where( or_( Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked", Vehicle.next_service_km <= Vehicle.odometer_km, ) ) total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 page_number = page or 1 vehicles = db.scalars( stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) ).all() vehicle_ids = [vehicle.id for vehicle in vehicles] next_bookings: dict[uuid.UUID, Booking] = {} if vehicle_ids: for booking in db.scalars( select(Booking) .where( Booking.vehicle_id.in_(vehicle_ids), Booking.status == "reserved", Booking.starts_at >= datetime.now(UTC), ) .order_by(Booking.starts_at.asc()) ).all(): next_bookings.setdefault(booking.vehicle_id, booking) items = [ VehicleOut( public_ref=v.public_ref, make=v.make, model=v.model, model_year=v.model_year, registration_number=v.registration_number, location=v.location, operational_status=v.operational_status, odometer_km=v.odometer_km, next_service_km=v.next_service_km, active=v.active, attention=( v.id in attention_ids or v.operational_status == "blocked" or v.next_service_km <= v.odometer_km ), attention_reason=( "blocked_status" if v.operational_status == "blocked" else "service_due" if v.next_service_km <= v.odometer_km else "data_quality" if v.id in attention_ids else None ), service_remaining_km=v.next_service_km - v.odometer_km, next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None), next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None), ) for v in vehicles ] if page is None: return items total_pages = max(1, (total + page_size - 1) // page_size) return VehiclePageOut( items=items, page=min(page_number, total_pages), page_size=page_size, total=total, total_pages=total_pages, ) @router.get("/{public_ref}", response_model=VehicleDetailOut) def get_vehicle( public_ref: str, db: Session = Depends(get_db), user: CurrentUser = Depends(get_current_user), ) -> VehicleDetailOut: vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref)) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") bookings = db.scalars( select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc()) ).all() customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()} inspections = db.scalars( select(Inspection) .where(Inspection.vehicle_id == vehicle.id) .order_by(Inspection.completed_at.desc()) ).all() maintenance = db.scalars( select(MaintenanceRecord) .where(MaintenanceRecord.vehicle_id == vehicle.id) .order_by(MaintenanceRecord.occurred_at.desc()) ).all() # Detailed data-quality evidence is an operations-manager surface. Employees still # get the operational vehicle record they need, but never receive hidden evidence in # the payload merely because the frontend omits the Quality tab. issues = ( db.scalars( select(DataQualityIssue) .where( DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id, ) .order_by(DataQualityIssue.detected_at.desc()) ).all() if user.role == "operations_manager" else [] ) booking_by_id = {b.id: b.public_ref for b in bookings} next_booking = next( ( booking for booking in sorted(bookings, key=lambda item: item.starts_at) if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC) ), None, ) attention_ids = _attention_vehicle_ids(db) return VehicleDetailOut( 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=( vehicle.id in attention_ids or vehicle.operational_status == "blocked" or vehicle.next_service_km <= vehicle.odometer_km ), attention_reason=( "blocked_status" if vehicle.operational_status == "blocked" else "service_due" if vehicle.next_service_km <= vehicle.odometer_km else "data_quality" if vehicle.id in attention_ids else None ), service_remaining_km=vehicle.next_service_km - vehicle.odometer_km, next_booking_ref=next_booking.public_ref if next_booking else None, next_booking_at=next_booking.starts_at if next_booking else None, bookings=[ BookingSummaryOut( public_ref=b.public_ref, customer_ref=customer_ref_by_id.get(b.customer_id, ""), vehicle_ref=vehicle.public_ref, starts_at=b.starts_at, ends_at=b.ends_at, status=b.status, ) for b in bookings ], inspections=[ InspectionOut( public_ref=i.public_ref, booking_ref=booking_by_id.get(i.booking_id, ""), type=i.type, fuel_level_percent=i.fuel_level_percent, cleanliness_ok=i.cleanliness_ok, damage_reported=i.damage_reported, technical_warning=i.technical_warning, odometer_km=i.odometer_km, completed_at=i.completed_at, ) for i in inspections ], maintenance=[ MaintenanceOut( public_ref=m.public_ref, occurred_at=m.occurred_at, odometer_km=m.odometer_km, category=m.category, summary=m.summary, ) for m in maintenance ], quality_issues=[ DataQualityIssueOut( public_ref=q.public_ref, rule_type=q.rule_type, entity_type=q.entity_type, entity_ref=vehicle.public_ref, severity=q.severity, status=q.status, evidence=q.evidence_json, detected_at=q.detected_at, due_at=q.due_at, assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None), assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None), overdue=( q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC) ), resolved_at=q.resolved_at, ) 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") correlation_id = uuid.uuid4() before_vehicle = { "operational_status": vehicle.operational_status, "odometer_km": vehicle.odometer_km, "next_service_km": vehicle.next_service_km, } 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) open_odometer_regression_issue( db, vehicle=vehicle, reading_ref=record.public_ref, reading_km=body.odometer_km, canonical_km=vehicle.odometer_km, source_type="maintenance", related_refs=[record.public_ref], actor_label=user.display_name, correlation_id=correlation_id, ) 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, correlation_id=correlation_id, before=before_vehicle, after={ "maintenance_ref": record.public_ref, "operational_status": vehicle.operational_status, "odometer_km": vehicle.odometer_km, "next_service_km": vehicle.next_service_km, }, ) 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=vehicle.next_service_km <= vehicle.odometer_km, attention_reason=( "service_due" if vehicle.next_service_km <= vehicle.odometer_km else None ), service_remaining_km=vehicle.next_service_km - vehicle.odometer_km, next_booking_ref=None, next_booking_at=None, )