Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

315
app/services/work_orders.py Normal file
View File

@@ -0,0 +1,315 @@
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