Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

337
app/api/work_orders.py Normal file
View File

@@ -0,0 +1,337 @@
from datetime import UTC, datetime
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,
CarServiceLink,
ServiceAppointment,
ServiceProductItem,
ServiceVisit,
ServiceWorkItem,
WorkOrderCorrection,
WorkOrderStatusHistory,
)
from app.models.user import User
from app.schemas.service_center import (
ServiceProductItemCreate,
ServiceProductItemRead,
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
WorkOrderCorrectionCreate,
WorkOrderCorrectionRead,
WorkOrderDecision,
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"])
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 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")
@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.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}",
)
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"})
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}/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())