diff --git a/app/api/service_visits.py b/app/api/service_visits.py index 60c2555..d74c06f 100644 --- a/app/api/service_visits.py +++ b/app/api/service_visits.py @@ -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, diff --git a/app/api/work_orders.py b/app/api/work_orders.py index 5c9cd4a..085b6e7 100644 --- a/app/api/work_orders.py +++ b/app/api/work_orders.py @@ -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, diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py index cd411bf..e33d62c 100644 --- a/app/schemas/service_center.py +++ b/app/schemas/service_center.py @@ -489,6 +489,7 @@ class WorkOrderUpdate(BaseModel): class WorkOrderDecision(BaseModel): comment: str | None = None + odometer: int | None = None confirm_lower_odometer: bool = False diff --git a/tests/test_platform.py b/tests/test_platform.py index 67b4384..eaab197 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -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 diff --git a/tests/test_production_flows.py b/tests/test_production_flows.py index c8ba2b0..dff040d 100644 --- a/tests/test_production_flows.py +++ b/tests/test_production_flows.py @@ -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" diff --git a/web/static/app.js b/web/static/app.js index 901d375..34d100c 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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(); })); diff --git a/web/static/sto.js b/web/static/sto.js index d1ffe49..e5bae6b 100644 --- a/web/static/sto.js +++ b/web/static/sto.js @@ -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) {