Files
drivers_bot/app/api/service_visits.py
2026-05-12 19:45:08 +09:00

206 lines
8.8 KiB
Python

from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
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.user import User
from app.schemas.service_center import (
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
VehicleDataChangeRequestCreate,
VehicleDataChangeRequestRead,
)
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
@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)
if visit.odometer and (vehicle.current_odometer is None or visit.odometer > vehicle.current_odometer):
vehicle.current_odometer = visit.odometer
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)