Sync completed work orders into vehicle records
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 12:17:45 +09:00
parent ecfb5aa949
commit 069b0a66c0
7 changed files with 68 additions and 4 deletions

View File

@@ -1,11 +1,14 @@
from datetime import UTC, datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
from app.db.session import get_session
from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest
from app.models.expense import ExpenseCategory, ExpenseEntry, ServiceEntry, ServiceType
from app.models.user import User
from app.schemas.service_center import (
ServiceVisitRead,
@@ -27,6 +30,51 @@ async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit
return visit
async def ensure_visit_owner_records(session: AsyncSession, visit: ServiceVisit, vehicle: Car) -> None:
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
amount = Decimal(str(visit.final_total or visit.total_cost or 0)).quantize(Decimal("0.01"))
title = f"Визит СТО #{visit.id}"
if service is None:
session.add(
ServiceEntry(
car_id=vehicle.id,
service_visit_id=visit.id,
entry_date=visit.visit_date,
odometer=visit.odometer,
service_type=ServiceType.maintenance,
title=title,
category="service_visit",
total_cost=amount,
notes=visit.service_comment or visit.notes,
)
)
if expense is None:
session.add(
ExpenseEntry(
car_id=vehicle.id,
service_visit_id=visit.id,
entry_date=visit.visit_date,
category=ExpenseCategory.maintenance,
title=title,
total_cost=max(amount, Decimal("0")),
currency=visit.currency,
odometer=visit.odometer,
metadata_json={"service_visit_id": visit.id},
)
)
@router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
async def add_work_item(
visit_id: int,
@@ -84,6 +132,7 @@ async def confirm_visit(
raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "confirmed"
visit.owner_resolved_at = datetime.now(UTC)
await ensure_visit_owner_records(session, visit, vehicle)
await apply_odometer_from_record(
session,
vehicle,

View File

@@ -487,6 +487,8 @@ async def complete_work_order(
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"})
if payload.odometer is not None:
visit.odometer = payload.odometer
await close_work_order(
session,
visit,

View File

@@ -489,6 +489,7 @@ class WorkOrderUpdate(BaseModel):
class WorkOrderDecision(BaseModel):
comment: str | None = None
odometer: int | None = None
confirm_lower_odometer: bool = False

View File

@@ -73,6 +73,16 @@ async def test_service_visit_owner_confirmation_and_change_request(
assert complete_response.json()["status"] == "pending_owner_confirmation"
assert confirm_response.json()["status"] == "confirmed"
assert approve_response.json()["status"] == "approved"
refreshed = await client.get(f"/api/cars/{vehicle['id']}", headers=auth_headers)
services = await client.get(f"/api/cars/{vehicle['id']}/service", headers=auth_headers)
stats = await client.get(
f"/api/cars/{vehicle['id']}/stats?date_from=2026-05-01&date_to=2026-05-31",
headers=auth_headers,
)
assert refreshed.json()["current_odometer"] == 12345
assert services.json()[0]["total_cost"] == "100.00"
assert stats.json()["service_cost"] == "100.00"
assert stats.json()["total_cost"] == "100.00"
@pytest.mark.asyncio

View File

@@ -169,7 +169,7 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
completed = await client.post(
f"/api/work-orders/{work_order['id']}/complete",
headers=auth_headers,
json={},
json={"odometer": 10300},
)
assert completed.status_code == 200
assert completed.json()["status"] == "completed"
@@ -210,7 +210,7 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs(
assert sum(1 for item in service_history.json()["service_visits"] if item["id"] == work_order["id"]) == 1
assert len(expenses.json()) == 1
assert expenses.json()[0]["total_cost"] == "130.00"
assert refreshed.json()["current_odometer"] == 10150
assert refreshed.json()["current_odometer"] == 10300
assert refreshed.json()["engine_oil_type"] == "5W-30"
assert refreshed.json()["engine_oil_volume_l"] == "4.00"
assert stats.json()["total_cost"] == "130.00"

View File

@@ -1658,9 +1658,10 @@ function bindMechanicWorkplaceActions(root) {
});
root.querySelectorAll("[data-complete-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Завершаю заказ-наряд...", async () => {
const odometerValue = window.prompt("Пробег на закрытии, км. Можно оставить пустым, если пробег уже указан.") || "";
await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }),
body: JSON.stringify({ comment: "Работы завершены", odometer: numberOrNull(odometerValue) }),
});
await loadMechanicWorkplace();
}));

View File

@@ -445,9 +445,10 @@ document.body.addEventListener("click", async (event) => {
}));
}
if (button.dataset.completeWorkOrder) {
const odometer = window.prompt("Пробег на закрытии, км. Можно оставить пустым, если пробег уже указан.") || "";
await runAction(button, () => api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }),
body: JSON.stringify({ comment: "Работы завершены", odometer: numberOrNull(odometer) }),
}));
}
if (button.dataset.requestVehicleProfile) {