301 lines
11 KiB
Python
301 lines
11 KiB
Python
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, 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
|
|
|
|
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),
|
|
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 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")
|
|
)
|
|
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()
|
|
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",
|
|
)
|
|
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()
|
|
issues = db.scalars(
|
|
select(DataQualityIssue)
|
|
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id)
|
|
.order_by(DataQualityIssue.detected_at.desc())
|
|
).all()
|
|
|
|
booking_by_id = {b.id: b.public_ref for b in bookings}
|
|
|
|
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",
|
|
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,
|
|
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")
|
|
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,
|
|
)
|