262 lines
11 KiB
Python
262 lines
11 KiB
Python
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)
|