213 lines
9.0 KiB
Python
213 lines
9.0 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.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
|
|
|
|
|
|
@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 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)
|