from datetime import UTC, datetime from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit from app.db.session import get_session from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest from app.models.expense import ExpenseCategory, ExpenseEntry, ServiceEntry, ServiceType from app.models.user import User from app.schemas.service_center import ( ServiceVisitRead, ServiceWorkItemCreate, ServiceWorkItemRead, VehicleDataChangeRequestCreate, VehicleDataChangeRequestRead, ) from app.services.odometer import apply_odometer_from_record from app.services.vehicle_identity import normalize_license_plate, validate_vin router = APIRouter(prefix="/service-visits", tags=["service-visits"]) async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit: visit = await session.get(ServiceVisit, visit_id) if visit is None: raise HTTPException(status_code=404, detail="Service visit not found") return visit async def ensure_visit_owner_records(session: AsyncSession, visit: ServiceVisit, vehicle: Car) -> None: service = ( await session.execute( select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id) ) ).scalar_one_or_none() expense = ( await session.execute( select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id) ) ).scalar_one_or_none() if service is not None and expense is not None: return amount = Decimal(str(visit.final_total or visit.total_cost or 0)).quantize(Decimal("0.01")) title = f"Визит СТО #{visit.id}" if service is None: session.add( ServiceEntry( car_id=vehicle.id, service_visit_id=visit.id, entry_date=visit.visit_date, odometer=visit.odometer, service_type=ServiceType.maintenance, title=title, category="service_visit", total_cost=amount, notes=visit.service_comment or visit.notes, ) ) if expense is None: session.add( ExpenseEntry( car_id=vehicle.id, service_visit_id=visit.id, entry_date=visit.visit_date, category=ExpenseCategory.maintenance, title=title, total_cost=max(amount, Decimal("0")), currency=visit.currency, odometer=visit.odometer, metadata_json={"service_visit_id": visit.id}, ) ) @router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED) async def add_work_item( visit_id: int, payload: ServiceWorkItemCreate, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceWorkItem: visit = await get_visit_or_404(session, visit_id) await ensure_service_employee( session, visit.service_center_id, current_user, {"owner", "manager", "mechanic"}, ) if visit.status not in {"draft", "pending_owner_confirmation"}: raise HTTPException(status_code=409, detail="Visit cannot be edited in current status") item = ServiceWorkItem(service_visit_id=visit_id, **payload.model_dump()) session.add(item) if payload.price is not None: visit.total_cost = (visit.total_cost or 0) + payload.price await log_audit(session, actor=current_user, action="service_work_item.create", target_type="service_visit", target_id=visit_id) await session.commit() await session.refresh(item) return item @router.post("/{visit_id}/complete", response_model=ServiceVisitRead) async def complete_visit( visit_id: int, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceVisit: visit = await get_visit_or_404(session, visit_id) await ensure_service_employee(session, visit.service_center_id, current_user, {"owner", "manager"}) if visit.status not in {"draft", "pending_owner_confirmation"}: raise HTTPException(status_code=409, detail="Visit cannot be completed") visit.status = "pending_owner_confirmation" await log_audit(session, actor=current_user, action="service_visit.complete", target_type="service_visit", target_id=visit_id) await session.commit() await session.refresh(visit) return visit @router.post("/{visit_id}/confirm", response_model=ServiceVisitRead) async def confirm_visit( visit_id: int, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceVisit: visit = await get_visit_or_404(session, visit_id) vehicle = await session.get(Car, visit.vehicle_id) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") if vehicle.owner_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") visit.status = "confirmed" visit.owner_resolved_at = datetime.now(UTC) await ensure_visit_owner_records(session, visit, vehicle) await apply_odometer_from_record( session, vehicle, new_odometer=visit.odometer, source_record_type="service_visit", source_record_id=visit.id, changed_by=current_user.id, ) await log_audit(session, actor=current_user, action="service_visit.confirm", target_type="service_visit", target_id=visit_id) await session.commit() await session.refresh(visit) return visit @router.post("/{visit_id}/dispute", response_model=ServiceVisitRead) async def dispute_visit( visit_id: int, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> ServiceVisit: visit = await get_visit_or_404(session, visit_id) vehicle = await session.get(Car, visit.vehicle_id) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") if vehicle.owner_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") visit.status = "disputed" visit.owner_resolved_at = datetime.now(UTC) await log_audit(session, actor=current_user, action="service_visit.dispute", target_type="service_visit", target_id=visit_id) await session.commit() await session.refresh(visit) return visit @router.post("/{visit_id}/vehicle-change-requests", response_model=VehicleDataChangeRequestRead) async def create_vehicle_change_request( visit_id: int, payload: VehicleDataChangeRequestCreate, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> VehicleDataChangeRequest: visit = await get_visit_or_404(session, visit_id) employee = await ensure_service_employee( session, visit.service_center_id, current_user, {"owner", "manager", "mechanic", "receptionist"}, ) if visit.vehicle_id != payload.vehicle_id: raise HTTPException(status_code=400, detail="Vehicle does not match visit") vehicle = await session.get(Car, payload.vehicle_id) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") old_value = getattr(vehicle, payload.field_name, None) request = VehicleDataChangeRequest( vehicle_id=payload.vehicle_id, requested_by_service_center_id=visit.service_center_id, requested_by_employee_id=employee.id, field_name=payload.field_name, old_value=str(old_value) if old_value is not None else None, new_value=payload.new_value, status="pending", owner_user_id=vehicle.owner_id, ) session.add(request) await log_audit(session, actor=current_user, action="vehicle_change_request.create", target_type="vehicle", target_id=payload.vehicle_id, metadata={"field_name": payload.field_name}) await session.commit() await session.refresh(request) return request @router.post("/vehicle-change-requests/{request_id}/approve", response_model=VehicleDataChangeRequestRead) async def approve_vehicle_change_request( request_id: int, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> VehicleDataChangeRequest: request = await session.get(VehicleDataChangeRequest, request_id) if request is None: raise HTTPException(status_code=404, detail="Change request not found") if request.owner_user_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") vehicle = await session.get(Car, request.vehicle_id) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") apply_vehicle_change(vehicle, request.field_name, request.new_value) request.status = "approved" request.resolved_at = datetime.now(UTC) await log_audit(session, actor=current_user, action="vehicle_change_request.approve", target_type="vehicle_change_request", target_id=request_id) await session.commit() await session.refresh(request) return request @router.post("/vehicle-change-requests/{request_id}/reject", response_model=VehicleDataChangeRequestRead) async def reject_vehicle_change_request( request_id: int, session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_telegram_user), ) -> VehicleDataChangeRequest: request = await session.get(VehicleDataChangeRequest, request_id) if request is None: raise HTTPException(status_code=404, detail="Change request not found") if request.owner_user_id != current_user.id: raise HTTPException(status_code=403, detail="Forbidden") request.status = "rejected" request.resolved_at = datetime.now(UTC) await log_audit(session, actor=current_user, action="vehicle_change_request.reject", target_type="vehicle_change_request", target_id=request_id) await session.commit() await session.refresh(request) return request def apply_vehicle_change(vehicle: Car, field_name: str, value: str | None) -> None: if field_name in {"license_plate", "license_plate_display"}: vehicle.license_plate_display = value vehicle.license_plate_normalized = normalize_license_plate(value) vehicle.plate_number = value return if field_name in {"vin", "vin_normalized"}: vehicle.vin = value vehicle.vin_normalized = validate_vin(value) return if not hasattr(vehicle, field_name): raise HTTPException(status_code=400, detail="Unsupported vehicle field") setattr(vehicle, field_name, value)