Files
drivers_bot/app/api/service_visits.py
VPN SaaS Dev 069b0a66c0
Some checks failed
ci / test (push) Has been cancelled
Sync completed work orders into vehicle records
2026-05-16 12:17:45 +09:00

262 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)