Sync completed work orders into vehicle records
Some checks failed
ci / test (push) Has been cancelled
Some checks failed
ci / test (push) Has been cancelled
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user