Files
drivers_bot/app/services/work_orders.py
VPN SaaS Dev 545f4d088d
Some checks failed
ci / test (push) Has been cancelled
Add owner work order approval page
2026-05-16 10:51:05 +09:00

323 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"))
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",
web_app_url=work_order_webapp_url(visit.id),
button_text="Открыть заказ-наряд",
)
return service, expense