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.core.config import settings 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 work_order_webapp_url(work_order_id: int) -> str: return f"{settings.effective_webapp_url.rstrip('/')}/work_order.html?id={work_order_id}" 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")) def _product_profile_label(product: ServiceProductItem) -> str | None: details = [product.viscosity, product.specification] label = " ".join(str(item).strip() for item in details if item).strip() return label or product.specification or product.title or None def _product_volume(product: ServiceProductItem) -> Decimal | None: if product.used_volume is not None: return product.used_volume if product.volume is not None: return product.volume if product.unit == "l": return product.quantity return None def sync_vehicle_profile_from_products(vehicle: Car, product_items: list[ServiceProductItem]) -> dict[str, str]: updates: dict[str, str] = {} for product in product_items: category = product.category label = _product_profile_label(product) volume = _product_volume(product) if category == "engine_oil": if label and not vehicle.engine_oil_type: vehicle.engine_oil_type = label updates["engine_oil_type"] = label if volume is not None and vehicle.engine_oil_volume_l is None: vehicle.engine_oil_volume_l = volume updates["engine_oil_volume_l"] = str(volume) elif category == "transmission_fluid": if label and not vehicle.transmission_fluid_type: vehicle.transmission_fluid_type = label updates["transmission_fluid_type"] = label if volume is not None and vehicle.transmission_fluid_volume_l is None: vehicle.transmission_fluid_volume_l = volume updates["transmission_fluid_volume_l"] = str(volume) elif category == "coolant" and label and not vehicle.coolant_type: vehicle.coolant_type = label updates["coolant_type"] = label elif category == "brake_fluid" and label and not vehicle.brake_fluid_type: vehicle.brake_fluid_type = label updates["brake_fluid_type"] = label return updates 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, ) ) vehicle_profile_updates = sync_vehicle_profile_from_products(vehicle, product_items) 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, "vehicle_profile_updates": vehicle_profile_updates, } 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", web_app_url=work_order_webapp_url(visit.id), button_text="Открыть заказ-наряд", ) return service, expense