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 datetime import UTC, datetime
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
from app.db.session import get_session from app.db.session import get_session
from app.models.car import Car, ServiceVisit, ServiceWorkItem, VehicleDataChangeRequest 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.models.user import User
from app.schemas.service_center import ( from app.schemas.service_center import (
ServiceVisitRead, ServiceVisitRead,
@@ -27,6 +30,51 @@ async def get_visit_or_404(session: AsyncSession, visit_id: int) -> ServiceVisit
return visit 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) @router.post("/{visit_id}/work-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
async def add_work_item( async def add_work_item(
visit_id: int, visit_id: int,
@@ -84,6 +132,7 @@ async def confirm_visit(
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
visit.status = "confirmed" visit.status = "confirmed"
visit.owner_resolved_at = datetime.now(UTC) visit.owner_resolved_at = datetime.now(UTC)
await ensure_visit_owner_records(session, visit, vehicle)
await apply_odometer_from_record( await apply_odometer_from_record(
session, session,
vehicle, vehicle,

View File

@@ -487,6 +487,8 @@ async def complete_work_order(
) -> ServiceVisit: ) -> ServiceVisit:
visit = await get_work_order(session, work_order_id) visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"}) 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( await close_work_order(
session, session,
visit, visit,

View File

@@ -489,6 +489,7 @@ class WorkOrderUpdate(BaseModel):
class WorkOrderDecision(BaseModel): class WorkOrderDecision(BaseModel):
comment: str | None = None comment: str | None = None
odometer: int | None = None
confirm_lower_odometer: bool = False 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 complete_response.json()["status"] == "pending_owner_confirmation"
assert confirm_response.json()["status"] == "confirmed" assert confirm_response.json()["status"] == "confirmed"
assert approve_response.json()["status"] == "approved" 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 @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( completed = await client.post(
f"/api/work-orders/{work_order['id']}/complete", f"/api/work-orders/{work_order['id']}/complete",
headers=auth_headers, headers=auth_headers,
json={}, json={"odometer": 10300},
) )
assert completed.status_code == 200 assert completed.status_code == 200
assert completed.json()["status"] == "completed" 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 sum(1 for item in service_history.json()["service_visits"] if item["id"] == work_order["id"]) == 1
assert len(expenses.json()) == 1 assert len(expenses.json()) == 1
assert expenses.json()[0]["total_cost"] == "130.00" 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_type"] == "5W-30"
assert refreshed.json()["engine_oil_volume_l"] == "4.00" assert refreshed.json()["engine_oil_volume_l"] == "4.00"
assert stats.json()["total_cost"] == "130.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) => { root.querySelectorAll("[data-complete-work-order]").forEach((button) => {
button.addEventListener("click", () => runAction(button, "Завершаю заказ-наряд...", async () => { button.addEventListener("click", () => runAction(button, "Завершаю заказ-наряд...", async () => {
const odometerValue = window.prompt("Пробег на закрытии, км. Можно оставить пустым, если пробег уже указан.") || "";
await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, { await api(`/work-orders/${button.dataset.completeWorkOrder}/complete`, {
method: "POST", method: "POST",
body: JSON.stringify({ comment: "Работы завершены" }), body: JSON.stringify({ comment: "Работы завершены", odometer: numberOrNull(odometerValue) }),
}); });
await loadMechanicWorkplace(); await loadMechanicWorkplace();
})); }));

View File

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