from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import 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.vehicle import Vehicle from app.schemas import ( ApplyRecommendedStatusResult, CurrentUser, DataQualityIssueDetailOut, DataQualityIssueOut, MergeCustomersRequest, MergeCustomersResult, ProvideFieldsRequest, ResolveOdometerRegressionRequest, ResolveOverlapRequest, ScanResultOut, ) from app.services.data_quality import ( apply_recommended_status, defer_issue, merge_customers, provide_missing_fields, reject_issue, resolve_booking_overlap, resolve_odometer_regression, run_scan, ) router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"]) def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut: return DataQualityIssueOut( public_ref=issue.public_ref, rule_type=issue.rule_type, entity_type=issue.entity_type, entity_ref=issue.evidence_json.get("entity_ref", ""), severity=issue.severity, status=issue.status, evidence=issue.evidence_json, detected_at=issue.detected_at, resolved_at=issue.resolved_at, ) @router.get("/issues", response_model=list[DataQualityIssueOut]) def list_issues( status: str | None = Query(default=None), rule_type: str | None = Query(default=None), severity: str | None = Query(default=None), db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), ) -> list[DataQualityIssueOut]: stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) if status: stmt = stmt.where(DataQualityIssue.status == status) if rule_type: stmt = stmt.where(DataQualityIssue.rule_type == rule_type) if severity: stmt = stmt.where(DataQualityIssue.severity == severity) issues = db.scalars(stmt).all() return [_to_out(i) for i in issues] # Every public reference in this system carries its entity type in its own prefix # (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself, # not guessed from the issue's rule_type -- a booking_overlap issue's related refs are # bookings, not vehicles, and an inline odometer_regression issue's related refs mix a # booking and an inspection ref in the same list. _PREFIX_TO_TYPE = { "CUS-": "customer", "MO-": "vehicle", "BK-": "booking", "INSP-": "inspection", } def _entity_type_for_ref(ref: str) -> str | None: for prefix, entity_type in _PREFIX_TO_TYPE.items(): if ref.startswith(prefix): return entity_type return None def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None: if entity_type == "customer": customer = db.scalar(select(Customer).where(Customer.public_ref == ref)) if customer is None: return None return { "entity_type": "customer", "public_ref": customer.public_ref, "first_name": customer.first_name, "last_name": customer.last_name, "email": customer.email, "phone": customer.phone, "postal_code": customer.postal_code, "city": customer.city, } if entity_type == "vehicle": vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref)) if vehicle is None: return None return { "entity_type": "vehicle", "public_ref": vehicle.public_ref, "make": vehicle.make, "model": vehicle.model, "location": vehicle.location, "operational_status": vehicle.operational_status, "odometer_km": vehicle.odometer_km, } if entity_type == "booking": booking = db.scalar(select(Booking).where(Booking.public_ref == ref)) if booking is None: return None vehicle = db.get(Vehicle, booking.vehicle_id) customer = db.get(Customer, booking.customer_id) return { "entity_type": "booking", "public_ref": booking.public_ref, "status": booking.status, "starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat(), "vehicle_ref": vehicle.public_ref if vehicle else None, "customer_ref": customer.public_ref if customer else None, "end_odometer_km": booking.end_odometer_km, } if entity_type == "inspection": inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref)) if inspection is None: return None booking = db.get(Booking, inspection.booking_id) return { "entity_type": "inspection", "public_ref": inspection.public_ref, "type": inspection.type, "odometer_km": inspection.odometer_km, "completed_at": inspection.completed_at.isoformat(), "booking_ref": booking.public_ref if booking else None, } return None @router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut) def get_issue( public_ref: str, db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueDetailOut: issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)) if issue is None: raise HTTPException(status_code=404, detail="Data quality issue not found") base = _to_out(issue) related_refs = issue.evidence_json.get("related_refs", []) related_snapshots = [] for ref in related_refs: entity_type = _entity_type_for_ref(ref) if entity_type is None: continue snap = _snapshot(entity_type, ref, db) if snap is not None: related_snapshots.append(snap) return DataQualityIssueDetailOut( **base.model_dump(), entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db), related_snapshots=related_snapshots, ) @router.post("/issues/{public_ref}/defer", response_model=DataQualityIssueOut) def defer( public_ref: str, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = defer_issue(db, public_ref, user) return _to_out(issue) @router.post("/issues/{public_ref}/reject", response_model=DataQualityIssueOut) def reject( public_ref: str, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = reject_issue(db, public_ref, user) return _to_out(issue) @router.post("/issues/{public_ref}/merge-customers", response_model=MergeCustomersResult) def merge( public_ref: str, body: MergeCustomersRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> MergeCustomersResult: result = merge_customers(db, public_ref, body.survivor_ref, body.field_overrides, user) return MergeCustomersResult(**result) @router.post("/issues/{public_ref}/provide-fields", response_model=DataQualityIssueOut) def provide_fields( public_ref: str, body: ProvideFieldsRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = provide_missing_fields(db, public_ref, body.fields, user) return _to_out(issue) @router.post( "/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut ) def resolve_odometer( public_ref: str, body: ResolveOdometerRegressionRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = resolve_odometer_regression(db, public_ref, body, user) return _to_out(issue) @router.post("/issues/{public_ref}/resolve-overlap", response_model=DataQualityIssueOut) def resolve_overlap( public_ref: str, body: ResolveOverlapRequest, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> DataQualityIssueOut: issue = resolve_booking_overlap(db, public_ref, body.booking_ref, body.note, user) return _to_out(issue) @router.post( "/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult ) def apply_status( public_ref: str, db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> ApplyRecommendedStatusResult: issue, applied_status, reason = apply_recommended_status(db, public_ref, user) return ApplyRecommendedStatusResult( issue=_to_out(issue), applied_status=applied_status, reason=reason ) @router.post("/scan", response_model=ScanResultOut) def scan( db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> ScanResultOut: result = run_scan(db, actor=user) return ScanResultOut(created=result.created)