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())