Files
drivers_bot/app/api/work_orders.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

593 lines
25 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 fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
from app.core.config import settings
from app.db.session import get_session
from app.models.car import (
Car,
CarServiceLink,
ServiceAppointment,
ServiceCenter,
ServiceProductItem,
ServiceVisit,
ServiceWorkItem,
WorkOrderCatalogItem,
WorkOrderCorrection,
WorkOrderStatusHistory,
)
from app.models.user import User
from app.schemas.service_center import (
ServiceProductItemCreate,
ServiceProductItemRead,
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
VehicleProfileRequest,
WorkOrderCatalogItemCreate,
WorkOrderCatalogItemRead,
WorkOrderCatalogRead,
WorkOrderCatalogSuggestion,
WorkOrderCorrectionCreate,
WorkOrderCorrectionRead,
WorkOrderDecision,
WorkOrderDetailRead,
WorkOrderStatusHistoryRead,
WorkOrderUpdate,
)
from app.services.sto_booking import create_service_notification
from app.services.work_orders import (
add_labor_item,
add_product_item,
add_status_history,
assign_work_order_number,
close_work_order,
ensure_work_order_editable,
refresh_work_order_totals,
)
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
def webapp_url(path: str) -> str:
return f"{settings.effective_webapp_url.rstrip('/')}/{path.lstrip('/')}"
def work_order_webapp_url(work_order_id: int) -> str:
return webapp_url(f"work_order.html?id={work_order_id}")
def vehicle_profile_webapp_url(vehicle_id: int) -> str:
return webapp_url(f"car_profile.html?car_id={vehicle_id}")
async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit:
visit = await session.get(ServiceVisit, work_order_id)
if visit is None:
raise HTTPException(status_code=404, detail="Work order not found")
return visit
async def get_work_order_with_items(session: AsyncSession, work_order_id: int) -> ServiceVisit:
visit = (
await session.execute(
select(ServiceVisit)
.options(
selectinload(ServiceVisit.work_items),
selectinload(ServiceVisit.product_items),
)
.where(ServiceVisit.id == work_order_id)
)
).scalar_one_or_none()
if visit is None:
raise HTTPException(status_code=404, detail="Work order not found")
return visit
async def ensure_work_order_sto_access(
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
) -> None:
await ensure_service_employee(session, visit.service_center_id, user, allowed_roles)
await ensure_work_order_vehicle_scope(session, visit)
async def ensure_work_order_owner_access(session: AsyncSession, visit: ServiceVisit, user: User) -> Car:
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 != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return vehicle
async def ensure_work_order_vehicle_scope(session: AsyncSession, visit: ServiceVisit) -> None:
link = (
await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == visit.vehicle_id,
CarServiceLink.service_center_id == visit.service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
)
)
).scalar_one_or_none()
if link is not None:
return
appointment = (
await session.execute(
select(ServiceAppointment).where(
ServiceAppointment.linked_work_order_id == visit.id,
ServiceAppointment.service_center_id == visit.service_center_id,
ServiceAppointment.vehicle_id == visit.vehicle_id,
ServiceAppointment.status.in_(["converted_to_work_order", "completed"]),
)
)
).scalar_one_or_none()
if appointment is None:
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
async def ensure_center_vehicle_scope(session: AsyncSession, service_center_id: int, vehicle_id: int) -> None:
link = (
await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == vehicle_id,
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
)
)
).scalar_one_or_none()
if link is not None:
return
appointment = (
await session.execute(
select(ServiceAppointment).where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.vehicle_id == vehicle_id,
ServiceAppointment.status.in_(["confirmed", "confirmed_by_sto", "converted_to_work_order", "completed"]),
)
)
).scalar_one_or_none()
if appointment is None:
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
def vehicle_catalog_suggestions(vehicle: Car | None) -> tuple[list[WorkOrderCatalogSuggestion], list[str]]:
if vehicle is None:
return [], []
suggestions: list[WorkOrderCatalogSuggestion] = []
missing: list[str] = []
if vehicle.engine_oil_type:
suggestions.append(
WorkOrderCatalogSuggestion(
title=f"Моторное масло {vehicle.engine_oil_type}",
category="engine_oil",
product_type="fluid",
unit="l",
default_quantity=vehicle.engine_oil_volume_l or 1,
volume=vehicle.engine_oil_volume_l,
specification=vehicle.engine_oil_type,
metadata_json={"source_field": "engine_oil_type", "vehicle_id": vehicle.id},
)
)
else:
missing.append("engine_oil")
if vehicle.transmission_fluid_type:
suggestions.append(
WorkOrderCatalogSuggestion(
title=f"Трансмиссионная жидкость {vehicle.transmission_fluid_type}",
category="transmission_fluid",
product_type="fluid",
unit="l",
default_quantity=vehicle.transmission_fluid_volume_l or 1,
volume=vehicle.transmission_fluid_volume_l,
specification=vehicle.transmission_fluid_type,
metadata_json={"source_field": "transmission_fluid_type", "vehicle_id": vehicle.id},
)
)
else:
missing.append("transmission_fluid")
if vehicle.coolant_type:
suggestions.append(
WorkOrderCatalogSuggestion(
title=f"Антифриз {vehicle.coolant_type}",
category="coolant",
product_type="fluid",
unit="l",
specification=vehicle.coolant_type,
metadata_json={"source_field": "coolant_type", "vehicle_id": vehicle.id},
)
)
else:
missing.append("coolant")
if vehicle.brake_fluid_type:
suggestions.append(
WorkOrderCatalogSuggestion(
title=f"Тормозная жидкость {vehicle.brake_fluid_type}",
category="brake_fluid",
product_type="fluid",
unit="l",
specification=vehicle.brake_fluid_type,
metadata_json={"source_field": "brake_fluid_type", "vehicle_id": vehicle.id},
)
)
else:
missing.append("brake_fluid")
return suggestions, missing
async def load_catalog(
session: AsyncSession,
*,
service_center_id: int | None = None,
vehicle_id: int | None = None,
item_type: str | None = None,
) -> WorkOrderCatalogRead:
stmt = select(WorkOrderCatalogItem).where(WorkOrderCatalogItem.is_active.is_(True))
if service_center_id is not None:
stmt = stmt.where(
or_(
WorkOrderCatalogItem.service_center_id.is_(None),
WorkOrderCatalogItem.service_center_id == service_center_id,
)
)
else:
stmt = stmt.where(WorkOrderCatalogItem.service_center_id.is_(None))
if item_type:
stmt = stmt.where(WorkOrderCatalogItem.item_type == item_type)
stmt = stmt.order_by(WorkOrderCatalogItem.service_center_id.is_(None), WorkOrderCatalogItem.title.asc())
items = list((await session.execute(stmt)).scalars())
vehicle = await session.get(Car, vehicle_id) if vehicle_id else None
suggestions, missing = vehicle_catalog_suggestions(vehicle)
if item_type:
suggestions = [item for item in suggestions if item.item_type == item_type]
return WorkOrderCatalogRead(items=items, vehicle_suggestions=suggestions, missing_vehicle_fields=missing)
@router.get("/catalog", response_model=WorkOrderCatalogRead)
async def list_work_order_catalog(
service_center_id: int | None = None,
vehicle_id: int | None = None,
item_type: str | None = Query(default=None, pattern="^(work|product)$"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCatalogRead:
if service_center_id is not None:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist", "mechanic"})
if service_center_id is not None and vehicle_id is not None:
await ensure_center_vehicle_scope(session, service_center_id, vehicle_id)
return await load_catalog(session, service_center_id=service_center_id, vehicle_id=vehicle_id, item_type=item_type)
@router.post("/catalog", response_model=WorkOrderCatalogItemRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_catalog_item(
payload: WorkOrderCatalogItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCatalogItem:
if payload.service_center_id is None:
raise HTTPException(status_code=400, detail="Service center catalog item must have service_center_id")
await ensure_service_employee(session, payload.service_center_id, current_user, {"owner", "manager"})
item = WorkOrderCatalogItem(**payload.model_dump())
session.add(item)
await log_audit(session, actor=current_user, action="work_order_catalog.create", target_type="service_center", target_id=payload.service_center_id)
await session.commit()
await session.refresh(item)
return item
@router.get("/{work_order_id}", response_model=ServiceVisitRead)
async def get_work_order_detail(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_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:
return visit
await ensure_work_order_sto_access(session, visit, current_user)
return visit
@router.get("/{work_order_id}/detail", response_model=WorkOrderDetailRead)
async def get_work_order_rich_detail(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderDetailRead:
visit = await get_work_order_with_items(session, work_order_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:
await ensure_work_order_sto_access(session, visit, current_user)
center = await session.get(ServiceCenter, visit.service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
return WorkOrderDetailRead(
visit=visit,
vehicle=vehicle,
service_center=center,
work_items=list(visit.work_items),
product_items=list(visit.product_items),
catalog=await load_catalog(session, service_center_id=visit.service_center_id, vehicle_id=visit.vehicle_id),
)
@router.patch("/{work_order_id}", response_model=ServiceVisitRead)
async def update_work_order(
work_order_id: int,
payload: WorkOrderUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist", "mechanic"})
await ensure_work_order_editable(visit)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(visit, field, value)
await refresh_work_order_totals(session, visit)
await log_audit(session, actor=current_user, action="work_order.update", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/labor-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
async def create_labor_item(
work_order_id: int,
payload: ServiceWorkItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceWorkItem:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"})
item = await add_labor_item(session, visit, payload=payload.model_dump())
await log_audit(session, actor=current_user, action="work_order.labor_item.create", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(item)
return item
@router.post("/{work_order_id}/product-items", response_model=ServiceProductItemRead, status_code=status.HTTP_201_CREATED)
async def create_product_item(
work_order_id: int,
payload: ServiceProductItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceProductItem:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"})
item = await add_product_item(session, visit, payload=payload.model_dump())
await log_audit(session, actor=current_user, action="work_order.product_item.create", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(item)
return item
@router.post("/{work_order_id}/submit-approval", response_model=ServiceVisitRead)
async def submit_work_order_for_approval(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist"})
await ensure_work_order_editable(visit)
await assign_work_order_number(session, visit)
await refresh_work_order_totals(session, visit)
visit.approval_required = True
await add_status_history(session, visit, to_status="waiting_owner_approval", actor=current_user, comment=payload.comment)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
await create_service_notification(
session,
recipient_user_id=vehicle.owner_id,
service_center_id=visit.service_center_id,
notification_type="work_order.waiting_owner_approval",
title="Заказ-наряд ожидает согласования",
body=f"{visit.work_order_number}: {visit.final_total} {visit.currency}. Откройте детали, чтобы согласовать или отклонить смету.",
idempotency_key=f"work_order:{visit.id}:waiting_owner_approval:{visit.final_total}",
web_app_url=work_order_webapp_url(visit.id),
button_text="Согласовать заказ-наряд",
)
await log_audit(session, actor=current_user, action="work_order.submit_approval", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/approve", response_model=ServiceVisitRead)
async def approve_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_owner_access(session, visit, current_user)
if visit.status != "waiting_owner_approval":
raise HTTPException(status_code=409, detail="Work order is not waiting for owner approval")
await refresh_work_order_totals(session, visit)
visit.price_approved_total = visit.final_total
visit.approved_at = datetime.now(UTC)
visit.owner_resolved_at = visit.approved_at
visit.owner_comment = payload.comment
await add_status_history(session, visit, to_status="approved_by_owner", actor=current_user, comment=payload.comment)
await create_service_notification(
session,
recipient_user_id=visit.owner_id or current_user.id,
service_center_id=visit.service_center_id,
notification_type="work_order.approved_by_owner",
title="Заказ-наряд согласован",
body=visit.work_order_number,
send_telegram=False,
idempotency_key=f"work_order:{visit.id}:approved_by_owner",
)
await log_audit(session, actor=current_user, action="work_order.approve", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/reject", response_model=ServiceVisitRead)
async def reject_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_owner_access(session, visit, current_user)
if visit.status != "waiting_owner_approval":
raise HTTPException(status_code=409, detail="Work order is not waiting for owner approval")
visit.owner_comment = payload.comment
visit.owner_resolved_at = datetime.now(UTC)
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/start", response_model=ServiceVisitRead)
async def start_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"})
if visit.status not in {"draft", "diagnosis", "approved_by_owner"}:
raise HTTPException(status_code=409, detail="Work order cannot be started")
await add_status_history(session, visit, to_status="in_progress", actor=current_user, comment=payload.comment)
await log_audit(session, actor=current_user, action="work_order.start", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/complete", response_model=ServiceVisitRead)
async def complete_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"})
if payload.odometer is not None:
visit.odometer = payload.odometer
await close_work_order(
session,
visit,
actor=current_user,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/request-vehicle-profile", response_model=ServiceVisitRead)
async def request_vehicle_profile_details(
work_order_id: int,
payload: VehicleProfileRequest,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist", "mechanic"})
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
missing = payload.missing_fields or vehicle_catalog_suggestions(vehicle)[1]
missing_text = ", ".join(missing) if missing else "масло и технические жидкости"
await create_service_notification(
session,
recipient_user_id=vehicle.owner_id,
service_center_id=visit.service_center_id,
notification_type="vehicle_profile.requested_by_sto",
title="СТО просит заполнить карточку авто",
body=payload.comment or f"Для точного подбора материалов укажите данные: {missing_text}.",
idempotency_key=f"work_order:{visit.id}:vehicle_profile_request:{','.join(sorted(missing))}",
web_app_url=vehicle_profile_webapp_url(vehicle.id),
button_text="Заполнить карточку авто",
)
await log_audit(
session,
actor=current_user,
action="work_order.vehicle_profile.request",
target_type="service_visit",
target_id=visit.id,
metadata={"missing_fields": missing},
)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_correction(
work_order_id: int,
payload: WorkOrderCorrectionCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCorrection:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"})
if visit.status != "completed":
raise HTTPException(status_code=409, detail="Correction flow is only for completed work orders")
correction = WorkOrderCorrection(
service_visit_id=visit.id,
requested_by_user_id=current_user.id,
reason=payload.reason,
proposed_changes=payload.proposed_changes,
owner_approval_required=payload.owner_approval_required,
created_version=visit.version or 1,
)
session.add(correction)
await log_audit(
session,
actor=current_user,
action="work_order.correction.create",
target_type="service_visit",
target_id=visit.id,
metadata={"reason": payload.reason},
)
await session.commit()
await session.refresh(correction)
return correction
@router.get("/{work_order_id}/status-history", response_model=list[WorkOrderStatusHistoryRead])
async def work_order_status_history(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[WorkOrderStatusHistory]:
visit = await get_work_order(session, work_order_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:
await ensure_work_order_sto_access(session, visit, current_user)
result = await session.execute(
select(WorkOrderStatusHistory)
.where(WorkOrderStatusHistory.service_visit_id == visit.id)
.order_by(WorkOrderStatusHistory.created_at.asc(), WorkOrderStatusHistory.id.asc())
)
return list(result.scalars())