from __future__ import annotations from datetime import UTC, date, datetime from decimal import Decimal from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.car import ( Car, InventoryTransaction, MaintenanceRecommendation, ServiceAppointment, ServiceCenter, ServiceProductItem, ServiceVisit, ServiceWorkItem, WorkOrderStatusHistory, ) from app.models.expense import ExpenseCategory, ExpenseEntry, ServiceEntry, ServiceType from app.models.user import User from app.services.odometer import apply_odometer_from_record, validate_odometer_change from app.services.sto_booking import create_service_notification WORK_ORDER_STATUSES = { "draft", "diagnosis", "waiting_owner_approval", "approved_by_owner", "rejected_by_owner", "in_progress", "completed", "cancelled", "archived", } LOCKED_WORK_ORDER_STATUSES = {"completed", "cancelled", "archived"} def money(value: Decimal | int | float | None) -> Decimal: return Decimal(str(value or 0)).quantize(Decimal("0.01")) def line_total(quantity: Decimal, unit_price: Decimal | None, discount: Decimal) -> Decimal: return max(Decimal("0"), Decimal(quantity) * money(unit_price) - money(discount)).quantize(Decimal("0.01")) async def add_status_history( session: AsyncSession, visit: ServiceVisit, *, to_status: str, actor: User | None, comment: str | None = None, ) -> None: if to_status not in WORK_ORDER_STATUSES and to_status not in {"pending_owner_confirmation", "confirmed", "disputed"}: raise HTTPException(status_code=400, detail="Unsupported work order status") from_status = visit.status if from_status == to_status: return visit.status = to_status session.add( WorkOrderStatusHistory( service_visit_id=visit.id, from_status=from_status, to_status=to_status, changed_by_user_id=actor.id if actor else None, comment=comment, ) ) async def ensure_work_order_editable(visit: ServiceVisit) -> None: if visit.status in LOCKED_WORK_ORDER_STATUSES: raise HTTPException(status_code=409, detail="Completed or archived work order cannot be changed") async def refresh_work_order_totals(session: AsyncSession, visit: ServiceVisit) -> None: work_items = list( ( await session.execute( select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id) ) ).scalars() ) product_items = list( ( await session.execute( select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id) ) ).scalars() ) labor_total = sum((money(item.total if item.total is not None else item.price) for item in work_items), Decimal("0")) product_total = sum((money(item.total) for item in product_items), Decimal("0")) discount_total = money(visit.discount_total) final_total = max(Decimal("0"), labor_total + product_total - discount_total).quantize(Decimal("0.01")) visit.labor_total = labor_total.quantize(Decimal("0.01")) visit.product_total = product_total.quantize(Decimal("0.01")) visit.final_total = final_total visit.total_cost = final_total if visit.status == "approved_by_owner" and visit.price_approved_total is not None and final_total != visit.price_approved_total: visit.status = "waiting_owner_approval" visit.approved_at = None async def assign_work_order_number(session: AsyncSession, visit: ServiceVisit) -> None: if visit.work_order_number: return await session.flush() visit.work_order_number = f"WO-{date.today():%Y%m%d}-{visit.id:06d}" async def add_labor_item( session: AsyncSession, visit: ServiceVisit, *, payload: dict, ) -> ServiceWorkItem: await ensure_work_order_editable(visit) quantity = Decimal(str(payload.get("quantity") or 1)) unit_price = payload.get("unit_price") legacy_price = payload.get("price") total = line_total(quantity, money(unit_price if unit_price is not None else legacy_price), Decimal(str(payload.get("discount") or 0))) item = ServiceWorkItem(**payload, service_visit_id=visit.id, total=total) if item.price is None: item.price = total session.add(item) await session.flush() await refresh_work_order_totals(session, visit) return item async def add_product_item( session: AsyncSession, visit: ServiceVisit, *, payload: dict, ) -> ServiceProductItem: await ensure_work_order_editable(visit) quantity = Decimal(str(payload.get("quantity") or 1)) unit_price = Decimal(str(payload.get("unit_price") or 0)) discount = Decimal(str(payload.get("discount") or 0)) item = ServiceProductItem(**payload, service_visit_id=visit.id, total=line_total(quantity, unit_price, discount)) session.add(item) await session.flush() await refresh_work_order_totals(session, visit) return item async def close_work_order( session: AsyncSession, visit: ServiceVisit, *, actor: User, confirm_lower_odometer: bool = False, ) -> tuple[ServiceEntry, ExpenseEntry]: if visit.status == "completed": 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 service, expense raise HTTPException(status_code=409, detail="Completed work order is missing immutable records") if visit.status not in {"approved_by_owner", "in_progress", "diagnosis", "draft"}: raise HTTPException(status_code=409, detail="Work order must be approved or in progress before completion") if visit.approval_required and visit.status != "approved_by_owner": raise HTTPException(status_code=409, detail="Owner approval is required before completion") vehicle = await session.get(Car, visit.vehicle_id) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") owner = await session.get(User, visit.owner_id or vehicle.owner_id) if owner is None: raise HTTPException(status_code=404, detail="Vehicle owner not found") validate_odometer_change( vehicle, visit.odometer, source_record_type="work_order", confirm_lower_odometer=confirm_lower_odometer, ) await refresh_work_order_totals(session, visit) existing_service = ( await session.execute(select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id)) ).scalar_one_or_none() existing_expense = ( await session.execute(select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id)) ).scalar_one_or_none() if existing_service is not None or existing_expense is not None: raise HTTPException(status_code=409, detail="Work order completion records already exist") center = await session.get(ServiceCenter, visit.service_center_id) vendor_name = center.display_name or center.name if center else None service = ServiceEntry( car_id=vehicle.id, service_visit_id=visit.id, entry_date=visit.visit_date, odometer=visit.odometer, service_type=ServiceType.maintenance, title=f"Заказ-наряд {visit.work_order_number or visit.id}", category="sto_work_order", vendor=vendor_name, total_cost=visit.final_total, notes=visit.service_comment or visit.notes, ) expense = ExpenseEntry( car_id=vehicle.id, service_visit_id=visit.id, entry_date=visit.visit_date, category=ExpenseCategory.maintenance, title=f"СТО: заказ-наряд {visit.work_order_number or visit.id}", vendor=vendor_name, total_cost=max(visit.final_total, Decimal("0.01")), currency=visit.currency, odometer=visit.odometer, metadata_json={ "service_visit_id": visit.id, "work_order_number": visit.work_order_number, "labor_total": str(visit.labor_total), "product_total": str(visit.product_total), }, ) session.add_all([service, expense]) await session.flush() await apply_odometer_from_record( session, vehicle, new_odometer=visit.odometer, source_record_type="work_order", source_record_id=visit.id, changed_by=actor.id, confirm_lower_odometer=confirm_lower_odometer, ) visit.completed_at = datetime.now(UTC) await add_status_history(session, visit, to_status="completed", actor=actor, comment="Work order completed") appointment = ( await session.execute( select(ServiceAppointment).where(ServiceAppointment.linked_work_order_id == visit.id) ) ).scalar_one_or_none() if appointment is not None: appointment.status = "completed" if appointment and appointment.source_recommendation_id: recommendation = await session.get(MaintenanceRecommendation, appointment.source_recommendation_id) if recommendation is not None: recommendation.status = "completed" work_items = list( ( await session.execute( select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id) ) ).scalars() ) product_items = list( ( await session.execute( select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id) ) ).scalars() ) for product in product_items: session.add( InventoryTransaction( service_center_id=visit.service_center_id, service_visit_id=visit.id, product_item_id=product.id, transaction_type="consume", sku=product.sku, title=product.title, quantity=product.quantity, unit=product.unit, actor_user_id=actor.id, metadata_json={"source": "work_order_completion"}, ) ) for item in work_items: if item.next_due_date or item.next_due_odometer: session.add( MaintenanceRecommendation( vehicle_id=vehicle.id, recommendation_type=item.work_type or "maintenance", title=f"Следующее ТО: {item.title}", due_odometer_km=item.next_due_odometer, due_date=item.next_due_date, priority="medium", status="active", source="work_order", source_service_center_id=visit.service_center_id, source_appointment_id=appointment.id if appointment else None, ) ) visit.version = (visit.version or 1) + 1 visit.completed_snapshot = { "work_order_number": visit.work_order_number, "vehicle_id": vehicle.id, "service_center_id": visit.service_center_id, "odometer": visit.odometer, "labor_total": str(visit.labor_total), "product_total": str(visit.product_total), "discount_total": str(visit.discount_total), "final_total": str(visit.final_total), "currency": visit.currency, "completed_at": visit.completed_at.isoformat() if visit.completed_at else None, } await create_service_notification( session, recipient_user_id=owner.id, service_center_id=visit.service_center_id, appointment_id=appointment.id if appointment else None, notification_type="work_order.completed", title="Работа по заказ-наряду завершена", body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.", idempotency_key=f"work_order:{visit.id}:completed", ) return service, expense