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

View File

@@ -1,21 +1,29 @@
import re
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi import APIRouter, Depends, File, Request, UploadFile
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_telegram_user
from app.db.session import get_session
from app.models.user import User
from app.services.ocr_provider import get_ocr_provider
from app.services.rate_limit import check_rate_limit
from app.services.uploads import SAFE_IMAGE_TYPES, SAFE_TEXT_TYPES, validate_upload
router = APIRouter(prefix="/ocr", tags=["ocr"])
MAX_OCR_FILE_BYTES = 8 * 1024 * 1024
class ReceiptSuggestion(BaseModel):
entry_date: date | None = None
total_cost: Decimal | None = None
liters: Decimal | None = None
price_per_liter: Decimal | None = None
station: str | None = None
category: str | None = None
confidence: float
message: str
@@ -34,10 +42,20 @@ class OCRResultRead(BaseModel):
@router.post("/parse-text-receipt", response_model=ReceiptSuggestion)
async def parse_text_receipt(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> ReceiptSuggestion:
await check_rate_limit(scope="ocr", limit=10, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read()
validate_upload(
content=content,
filename=file.filename,
content_type=file.content_type,
max_bytes=MAX_OCR_FILE_BYTES,
allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES,
)
content_type = (file.content_type or "").lower()
if content_type.startswith("image/") or content_type == "application/pdf":
result = await get_ocr_provider().recognize(content, file.filename)
@@ -62,6 +80,7 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
numbers = [Decimal(item) for item in re.findall(r"\d+(?:\.\d+)?", compact)]
station = detect_station(compact)
entry_date = detect_date(compact)
liters = find_liters(compact, numbers)
price = find_price_per_liter(compact, numbers)
total = find_total(compact, numbers, liters, price)
@@ -80,10 +99,12 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
confidence = max(0, min(float(confidence), 0.95))
return ReceiptSuggestion(
entry_date=entry_date,
total_cost=total,
liters=liters,
price_per_liter=price,
station=station,
category="fuel" if liters or price else None,
confidence=round(confidence, 2) if numbers else 0,
message=(
"Разобрал текст чека и заполнил форму. Проверь значения перед сохранением."
@@ -95,18 +116,25 @@ def parse_receipt_text(text: str) -> ReceiptSuggestion:
@router.post("/fuel-receipt", response_model=ReceiptSuggestion, deprecated=True)
async def scan_fuel_receipt(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> ReceiptSuggestion:
return await parse_text_receipt(file, current_user)
return await parse_text_receipt(request, file, current_user, session)
@router.post("/license-plate", response_model=OCRResultRead)
async def recognize_license_plate(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> OCRResultRead:
result = await get_ocr_provider().recognize(await file.read(), file.filename)
await check_rate_limit(scope="ocr_license_plate", limit=8, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read()
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
result = await get_ocr_provider().recognize(content, file.filename)
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "license_plate"],
@@ -116,10 +144,15 @@ async def recognize_license_plate(
@router.post("/vin", response_model=OCRResultRead)
async def recognize_vin(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> OCRResultRead:
result = await get_ocr_provider().recognize(await file.read(), file.filename)
await check_rate_limit(scope="ocr_vin", limit=8, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read()
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
result = await get_ocr_provider().recognize(content, file.filename)
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates if item.type == "vin"],
@@ -129,10 +162,15 @@ async def recognize_vin(
@router.post("/service-document", response_model=OCRResultRead)
async def recognize_service_document(
request: Request,
file: UploadFile = File(...),
current_user: User = Depends(get_current_telegram_user),
session: AsyncSession = Depends(get_session),
) -> OCRResultRead:
result = await get_ocr_provider().recognize(await file.read(), file.filename)
await check_rate_limit(scope="ocr_service_document", limit=8, window_seconds=60, request=request, user=current_user, session=session)
content = await file.read()
validate_upload(content=content, filename=file.filename, content_type=file.content_type, max_bytes=MAX_OCR_FILE_BYTES, allowed_types=SAFE_IMAGE_TYPES | SAFE_TEXT_TYPES)
result = await get_ocr_provider().recognize(content, file.filename)
return OCRResultRead(
recognized_text=result.recognized_text,
candidates=[OCRCandidateRead(**item.__dict__) for item in result.candidates],
@@ -158,6 +196,24 @@ def detect_station(text: str) -> str | None:
return None
def detect_date(text: str) -> date | None:
for pattern in (
r"\b(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})\b",
r"\b(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\b",
):
match = re.search(pattern, text)
if not match:
continue
first, second, third = [int(item) for item in match.groups()]
try:
if first > 1900:
return date(first, second, third)
return date(third, second, first)
except ValueError:
continue
return None
def decimal_from_match(match: re.Match[str] | None) -> Decimal | None:
if not match:
return None
@@ -183,9 +239,9 @@ def find_price_per_liter(text: str, numbers: list[Decimal]) -> Decimal | None:
]
for pattern in patterns:
value = decimal_from_match(re.search(pattern, text, re.IGNORECASE))
if value and Decimal("10") <= value <= Decimal("500"):
if value and Decimal("0.1") <= value <= Decimal("500"):
return value
candidates = [item for item in numbers if Decimal("10") <= item <= Decimal("500")]
candidates = [item for item in numbers if Decimal("0.1") <= item <= Decimal("500")]
return candidates[-1] if candidates else None

View File

@@ -1,6 +1,7 @@
from datetime import UTC, datetime
import secrets
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -48,6 +49,7 @@ from app.schemas.service_center import (
)
from app.services.notifications import notify_platform_moderators
from app.services.odometer import validate_odometer_change
from app.services.rate_limit import check_rate_limit
from app.services.vehicle_identity import mask_license_plate, mask_vin
router = APIRouter(prefix="/service-centers", tags=["service-centers"])
@@ -162,12 +164,17 @@ async def my_service_centers(
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceCenter]:
result = await session.execute(
select(ServiceCenter)
select(ServiceCenter, ServiceEmployee.role, ServiceEmployee.status)
.join(ServiceEmployee, ServiceEmployee.service_center_id == ServiceCenter.id)
.where(ServiceEmployee.user_id == current_user.id, ServiceEmployee.status == "active")
.order_by(ServiceCenter.created_at.desc())
)
return list(result.scalars())
centers = []
for center, role, employee_status in result.all():
center.employee_role = role
center.employee_status = employee_status
centers.append(center)
return centers
@router.get("/public", response_model=list[ServiceCenterPublicRead])
@@ -253,9 +260,11 @@ async def submit_verification(
async def invite_employee(
service_center_id: int,
payload: ServiceEmployeeInvite,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
await check_rate_limit(scope="employee_invite", limit=10, window_seconds=3600, request=request, user=current_user, session=session)
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager"})
user = await get_or_create_telegram_user(session, telegram_id=payload.telegram_id)
result = await session.execute(
@@ -272,18 +281,95 @@ async def invite_employee(
role=payload.role,
permissions=payload.permissions,
status="invited",
invite_token=secrets.token_urlsafe(32),
invite_expires_at=datetime.now(UTC) + timedelta(hours=payload.expires_in_hours),
)
session.add(employee)
else:
employee.role = payload.role
employee.permissions = payload.permissions
employee.status = "invited"
employee.invite_token = secrets.token_urlsafe(32)
employee.invite_expires_at = datetime.now(UTC) + timedelta(hours=payload.expires_in_hours)
employee.invite_revoked_at = None
employee.activated_at = None
await log_audit(session, actor=current_user, action="service_employee.invite", target_type="service_center", target_id=service_center_id, metadata={"telegram_id": payload.telegram_id})
await session.commit()
await session.refresh(employee)
return employee
@router.post("/employees/invites/{invite_token}/accept", response_model=ServiceEmployeeRead)
async def accept_employee_invite(
invite_token: str,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
result = await session.execute(
select(ServiceEmployee).where(ServiceEmployee.invite_token == invite_token)
)
employee = result.scalar_one_or_none()
if employee is None:
raise HTTPException(status_code=404, detail="Invite not found")
if employee.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Invite belongs to another Telegram account")
if employee.status != "invited":
raise HTTPException(status_code=409, detail="Invite is not active")
if employee.invite_revoked_at is not None:
raise HTTPException(status_code=409, detail="Invite was revoked")
if employee.invite_expires_at:
expires_at = employee.invite_expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
else:
expires_at = None
if expires_at and expires_at <= datetime.now(UTC):
employee.status = "expired"
await log_audit(session, actor=current_user, action="service_employee.invite_expired", target_type="service_employee", target_id=employee.id)
await session.commit()
raise HTTPException(status_code=409, detail="Invite expired")
employee.status = "active"
employee.activated_at = datetime.now(UTC)
employee.invite_token = None
await log_audit(
session,
actor=current_user,
action="service_employee.invite_accept",
target_type="service_employee",
target_id=employee.id,
)
await session.commit()
await session.refresh(employee)
return employee
@router.post("/employees/{employee_id}/revoke-invite", response_model=ServiceEmployeeRead)
async def revoke_employee_invite(
employee_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceEmployee:
employee = await session.get(ServiceEmployee, employee_id)
if employee is None:
raise HTTPException(status_code=404, detail="Employee not found")
await ensure_service_employee(session, employee.service_center_id, current_user, {"owner", "manager"})
if employee.status != "invited":
raise HTTPException(status_code=409, detail="Only invited employees can be revoked")
employee.status = "revoked"
employee.invite_revoked_at = datetime.now(UTC)
employee.invite_token = None
await log_audit(
session,
actor=current_user,
action="service_employee.invite_revoke",
target_type="service_employee",
target_id=employee.id,
)
await session.commit()
await session.refresh(employee)
return employee
@router.get("/{service_center_id}/visits", response_model=list[ServiceVisitRead])
async def service_center_visits(
service_center_id: int,
@@ -355,6 +441,7 @@ async def create_visit(
visit = ServiceVisit(
service_center_id=service_center_id,
vehicle_id=payload.vehicle_id,
owner_id=vehicle.owner_id,
created_by_employee_id=employee.id,
visit_date=payload.visit_date,
odometer=payload.odometer,
@@ -374,9 +461,11 @@ async def create_visit(
async def request_vehicle_access(
service_center_id: int,
payload: VehicleSearchRequest,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> VehicleSearchResult:
await check_rate_limit(scope="vehicle_access_request", limit=20, window_seconds=3600, request=request, user=current_user, session=session)
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
await ensure_service_center_approved(session, service_center_id)
stmt = select(Car)
@@ -610,9 +699,11 @@ async def service_center_reviews(
async def create_service_center_review(
service_center_id: int,
payload: ServiceCenterReviewCreate,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterReview:
await check_rate_limit(scope="service_review", limit=10, window_seconds=3600, request=request, user=current_user, session=session)
await ensure_service_center_approved(session, service_center_id)
result = await session.execute(
select(ServiceCenterReview).where(

View File

@@ -1,6 +1,6 @@
from datetime import UTC, date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -36,6 +36,7 @@ from app.schemas.sto_booking import (
ServiceCenterHolidayRead,
STODashboardRead,
)
from app.services.rate_limit import check_rate_limit
from app.services.sto_booking import (
calculate_available_slots,
create_service_notification,
@@ -46,6 +47,7 @@ from app.services.sto_booking import (
money_to_float,
notify_service_staff,
)
from app.services.work_orders import add_status_history, assign_work_order_number
APPROVED_SERVICE_STATUSES = {"verified", "approved"}
@@ -173,9 +175,11 @@ async def get_available_slots(
@router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED)
async def create_appointment(
payload: AppointmentCreate,
request: Request,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
await check_rate_limit(scope="appointment_create", limit=20, window_seconds=3600, request=request, user=current_user, session=session)
await _approved_service_center(session, payload.service_center_id)
vehicle = await _owned_vehicle(session, payload.vehicle_id, current_user)
duration = estimate_duration(payload.service_type, payload.estimated_duration_minutes)
@@ -253,15 +257,15 @@ async def cancel_appointment(
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be cancelled")
appointment.status = "cancelled_by_customer"
appointment.status = "cancelled_by_owner"
appointment.cancelled_at = datetime.now(UTC)
appointment.cancellation_reason = payload.reason
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.cancelled_by_customer",
notification_type="appointment.cancelled_by_owner",
title="Клиент отменил запись",
body=payload.reason,
appointment_id=appointment.id,
@@ -316,7 +320,7 @@ async def reject_proposed_time(
appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status != "proposed_new_time":
raise HTTPException(status_code=409, detail="Appointment has no proposed time")
appointment.status = "rejected"
appointment.status = "rejected_by_sto"
appointment.service_center_comment = payload.comment
await notify_service_staff(
session,
@@ -365,13 +369,13 @@ async def get_sto_dashboard(
confirmed_appointments = int(
(await session.execute(select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.status == "confirmed",
ServiceAppointment.status.in_(["confirmed", "confirmed_by_sto"]),
))).scalar_one() or 0
)
active_work_orders = int(
(await session.execute(select(func.count(ServiceVisit.id)).where(
ServiceVisit.service_center_id == service_center_id,
ServiceVisit.status.in_(["draft", "pending_owner_confirmation"]),
ServiceVisit.status.in_(["draft", "diagnosis", "waiting_owner_approval", "approved_by_owner", "in_progress", "pending_owner_confirmation"]),
))).scalar_one() or 0
)
completed_result = await session.execute(
@@ -465,7 +469,7 @@ async def confirm_appointment(
duration_minutes=appointment.estimated_duration_minutes,
exclude_appointment_id=appointment.id,
)
appointment.status = "confirmed"
appointment.status = "confirmed_by_sto"
appointment.confirmed_start_at = appointment.requested_start_at
appointment.confirmed_end_at = appointment.requested_end_at
appointment.service_center_comment = payload.comment
@@ -492,9 +496,9 @@ async def reject_appointment(
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be rejected")
appointment.status = "rejected"
appointment.status = "rejected_by_sto"
appointment.service_center_comment = payload.comment
await create_service_notification(
session,
@@ -519,7 +523,7 @@ async def propose_appointment_time(
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
if appointment.status in {"completed", "cancelled_by_customer", "cancelled_by_sto"}:
if appointment.status in {"completed", "cancelled_by_owner", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be changed")
duration = estimate_duration(appointment.service_type, payload.estimated_duration_minutes or appointment.estimated_duration_minutes)
proposed_start = _utc(payload.proposed_start_at)
@@ -559,7 +563,7 @@ async def create_work_order_from_appointment(
) -> ServiceVisit:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
employee = await ensure_service_employee(session, appointment.service_center_id, current_user, {"owner", "manager", "receptionist"})
if appointment.status != "confirmed":
if appointment.status not in {"confirmed", "confirmed_by_sto"}:
raise HTTPException(status_code=409, detail="Only confirmed appointment can become work order")
if appointment.linked_work_order_id:
visit = await session.get(ServiceVisit, appointment.linked_work_order_id)
@@ -569,15 +573,32 @@ async def create_work_order_from_appointment(
visit = ServiceVisit(
service_center_id=appointment.service_center_id,
vehicle_id=appointment.vehicle_id,
owner_id=appointment.owner_id,
created_by_employee_id=employee.id,
assigned_employee_id=employee.id,
visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(),
odometer=payload.odometer,
status="draft",
customer_complaint=appointment.customer_comment,
notes=payload.notes or appointment.customer_comment,
opened_at=datetime.now(UTC),
)
session.add(visit)
await session.flush()
await assign_work_order_number(session, visit)
await add_status_history(session, visit, to_status="diagnosis", actor=current_user, comment="Created from appointment")
appointment.linked_work_order_id = visit.id
appointment.status = "converted_to_work_order"
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="work_order.created",
title="СТО создало заказ-наряд",
body=visit.work_order_number,
idempotency_key=f"work_order:{visit.id}:created",
)
await log_audit(session, actor=current_user, action="appointment.create_work_order", target_type="service_appointment", target_id=appointment_id, metadata={"service_visit_id": visit.id})
await session.commit()
await session.refresh(visit)

View File

@@ -25,6 +25,7 @@ from app.schemas.user import (
UserUpsert,
WebAppAuthRequest,
)
from app.services.rate_limit import check_rate_limit
from app.services.telegram_auth import verify_login_widget, verify_webapp_init_data
router = APIRouter(prefix="/users", tags=["users"])
@@ -56,8 +57,11 @@ async def auth_config() -> AuthConfig:
@router.post("/webapp-auth", response_model=UserRead)
async def webapp_auth(
payload: WebAppAuthRequest, session: AsyncSession = Depends(get_session)
payload: WebAppAuthRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> User:
await check_rate_limit(scope="auth_webapp", limit=30, window_seconds=60, request=request, session=session)
user_data = verify_webapp_init_data(payload.init_data, settings.bot_token)
telegram_id = int(user_data["id"])
return await get_or_create_telegram_user(
@@ -72,8 +76,11 @@ async def webapp_auth(
@router.post("/telegram-login", response_model=UserRead)
async def telegram_login(
payload: TelegramLoginRequest, session: AsyncSession = Depends(get_session)
payload: TelegramLoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
) -> User:
await check_rate_limit(scope="auth_login", limit=12, window_seconds=60, request=request, session=session)
values = verify_login_widget(payload.model_dump(), settings.bot_token)
telegram_id = int(values["id"])
return await get_or_create_telegram_user(

337
app/api/work_orders.py Normal file
View File

@@ -0,0 +1,337 @@
from datetime import UTC, datetime
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,
CarServiceLink,
ServiceAppointment,
ServiceProductItem,
ServiceVisit,
ServiceWorkItem,
WorkOrderCorrection,
WorkOrderStatusHistory,
)
from app.models.user import User
from app.schemas.service_center import (
ServiceProductItemCreate,
ServiceProductItemRead,
ServiceVisitRead,
ServiceWorkItemCreate,
ServiceWorkItemRead,
WorkOrderCorrectionCreate,
WorkOrderCorrectionRead,
WorkOrderDecision,
WorkOrderStatusHistoryRead,
WorkOrderUpdate,
)
from app.services.sto_booking import create_service_notification
from app.services.work_orders import (
add_labor_item,
add_product_item,
add_status_history,
assign_work_order_number,
close_work_order,
ensure_work_order_editable,
refresh_work_order_totals,
)
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit:
visit = await session.get(ServiceVisit, work_order_id)
if visit is None:
raise HTTPException(status_code=404, detail="Work order not found")
return visit
async def ensure_work_order_sto_access(
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
) -> None:
await ensure_service_employee(session, visit.service_center_id, user, allowed_roles)
await ensure_work_order_vehicle_scope(session, visit)
async def ensure_work_order_owner_access(session: AsyncSession, visit: ServiceVisit, user: User) -> Car:
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return vehicle
async def ensure_work_order_vehicle_scope(session: AsyncSession, visit: ServiceVisit) -> None:
link = (
await session.execute(
select(CarServiceLink).where(
CarServiceLink.car_id == visit.vehicle_id,
CarServiceLink.service_center_id == visit.service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
)
)
).scalar_one_or_none()
if link is not None:
return
appointment = (
await session.execute(
select(ServiceAppointment).where(
ServiceAppointment.linked_work_order_id == visit.id,
ServiceAppointment.service_center_id == visit.service_center_id,
ServiceAppointment.vehicle_id == visit.vehicle_id,
ServiceAppointment.status.in_(["converted_to_work_order", "completed"]),
)
)
).scalar_one_or_none()
if appointment is None:
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
@router.get("/{work_order_id}", response_model=ServiceVisitRead)
async def get_work_order_detail(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id == current_user.id:
return visit
await ensure_work_order_sto_access(session, visit, current_user)
return visit
@router.patch("/{work_order_id}", response_model=ServiceVisitRead)
async def update_work_order(
work_order_id: int,
payload: WorkOrderUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist", "mechanic"})
await ensure_work_order_editable(visit)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(visit, field, value)
await refresh_work_order_totals(session, visit)
await log_audit(session, actor=current_user, action="work_order.update", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/labor-items", response_model=ServiceWorkItemRead, status_code=status.HTTP_201_CREATED)
async def create_labor_item(
work_order_id: int,
payload: ServiceWorkItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceWorkItem:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"})
item = await add_labor_item(session, visit, payload=payload.model_dump())
await log_audit(session, actor=current_user, action="work_order.labor_item.create", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(item)
return item
@router.post("/{work_order_id}/product-items", response_model=ServiceProductItemRead, status_code=status.HTTP_201_CREATED)
async def create_product_item(
work_order_id: int,
payload: ServiceProductItemCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceProductItem:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"})
item = await add_product_item(session, visit, payload=payload.model_dump())
await log_audit(session, actor=current_user, action="work_order.product_item.create", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(item)
return item
@router.post("/{work_order_id}/submit-approval", response_model=ServiceVisitRead)
async def submit_work_order_for_approval(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist"})
await ensure_work_order_editable(visit)
await assign_work_order_number(session, visit)
await refresh_work_order_totals(session, visit)
visit.approval_required = True
await add_status_history(session, visit, to_status="waiting_owner_approval", actor=current_user, comment=payload.comment)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
await create_service_notification(
session,
recipient_user_id=vehicle.owner_id,
service_center_id=visit.service_center_id,
notification_type="work_order.waiting_owner_approval",
title="Заказ-наряд ожидает согласования",
body=f"{visit.work_order_number}: {visit.final_total} {visit.currency}",
idempotency_key=f"work_order:{visit.id}:waiting_owner_approval:{visit.final_total}",
)
await log_audit(session, actor=current_user, action="work_order.submit_approval", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/approve", response_model=ServiceVisitRead)
async def approve_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_owner_access(session, visit, current_user)
if visit.status != "waiting_owner_approval":
raise HTTPException(status_code=409, detail="Work order is not waiting for owner approval")
await refresh_work_order_totals(session, visit)
visit.price_approved_total = visit.final_total
visit.approved_at = datetime.now(UTC)
visit.owner_resolved_at = visit.approved_at
visit.owner_comment = payload.comment
await add_status_history(session, visit, to_status="approved_by_owner", actor=current_user, comment=payload.comment)
await create_service_notification(
session,
recipient_user_id=visit.owner_id or current_user.id,
service_center_id=visit.service_center_id,
notification_type="work_order.approved_by_owner",
title="Заказ-наряд согласован",
body=visit.work_order_number,
send_telegram=False,
idempotency_key=f"work_order:{visit.id}:approved_by_owner",
)
await log_audit(session, actor=current_user, action="work_order.approve", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/reject", response_model=ServiceVisitRead)
async def reject_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_owner_access(session, visit, current_user)
if visit.status != "waiting_owner_approval":
raise HTTPException(status_code=409, detail="Work order is not waiting for owner approval")
visit.owner_comment = payload.comment
visit.owner_resolved_at = datetime.now(UTC)
await add_status_history(session, visit, to_status="rejected_by_owner", actor=current_user, comment=payload.comment)
await log_audit(session, actor=current_user, action="work_order.reject", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/start", response_model=ServiceVisitRead)
async def start_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "mechanic"})
if visit.status not in {"draft", "diagnosis", "approved_by_owner"}:
raise HTTPException(status_code=409, detail="Work order cannot be started")
await add_status_history(session, visit, to_status="in_progress", actor=current_user, comment=payload.comment)
await log_audit(session, actor=current_user, action="work_order.start", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/complete", response_model=ServiceVisitRead)
async def complete_work_order(
work_order_id: int,
payload: WorkOrderDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceVisit:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"})
await close_work_order(
session,
visit,
actor=current_user,
confirm_lower_odometer=payload.confirm_lower_odometer,
)
await log_audit(session, actor=current_user, action="work_order.complete", target_type="service_visit", target_id=visit.id)
await session.commit()
await session.refresh(visit)
return visit
@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_correction(
work_order_id: int,
payload: WorkOrderCorrectionCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> WorkOrderCorrection:
visit = await get_work_order(session, work_order_id)
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager"})
if visit.status != "completed":
raise HTTPException(status_code=409, detail="Correction flow is only for completed work orders")
correction = WorkOrderCorrection(
service_visit_id=visit.id,
requested_by_user_id=current_user.id,
reason=payload.reason,
proposed_changes=payload.proposed_changes,
owner_approval_required=payload.owner_approval_required,
created_version=visit.version or 1,
)
session.add(correction)
await log_audit(
session,
actor=current_user,
action="work_order.correction.create",
target_type="service_visit",
target_id=visit.id,
metadata={"reason": payload.reason},
)
await session.commit()
await session.refresh(correction)
return correction
@router.get("/{work_order_id}/status-history", response_model=list[WorkOrderStatusHistoryRead])
async def work_order_status_history(
work_order_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[WorkOrderStatusHistory]:
visit = await get_work_order(session, work_order_id)
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.owner_id != current_user.id:
await ensure_work_order_sto_access(session, visit, current_user)
result = await session.execute(
select(WorkOrderStatusHistory)
.where(WorkOrderStatusHistory.service_visit_id == visit.id)
.order_by(WorkOrderStatusHistory.created_at.asc(), WorkOrderStatusHistory.id.asc())
)
return list(result.scalars())