370 lines
14 KiB
Python
370 lines
14 KiB
Python
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
|