Add owner work order approval page
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:51:05 +09:00
parent ac5845d5a0
commit 545f4d088d
12 changed files with 1066 additions and 48 deletions

View File

@@ -1,18 +1,22 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
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,
)
@@ -23,9 +27,15 @@ from app.schemas.service_center import (
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
VehicleProfileRequest,
WorkOrderCatalogItemCreate,
WorkOrderCatalogItemRead,
WorkOrderCatalogRead,
WorkOrderCatalogSuggestion,
WorkOrderCorrectionCreate,
WorkOrderCorrectionRead,
WorkOrderDecision,
WorkOrderDetailRead,
WorkOrderStatusHistoryRead,
WorkOrderUpdate,
)
@@ -43,6 +53,18 @@ from app.services.work_orders import (
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"?section=carProfile&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:
@@ -50,6 +72,22 @@ async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVi
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:
@@ -93,6 +131,156 @@ async def ensure_work_order_vehicle_scope(session: AsyncSession, visit: ServiceV
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,
@@ -109,6 +297,31 @@ async def get_work_order_detail(
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,
@@ -183,8 +396,10 @@ async def submit_work_order_for_approval(
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}",
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()
@@ -284,6 +499,44 @@ async def complete_work_order(
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,