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())

View File

@@ -16,11 +16,16 @@ class Settings(BaseSettings):
cors_origins: str = ""
internal_api_token: str = ""
vapid_public_key: str = ""
vapid_private_key: str = ""
secret_key: str = ""
redis_url: str = ""
allow_dev_auth: bool = False
ocr_provider: str = "tesseract"
ocr_languages: str = "eng+rus+kor"
llm_base_url: str = ""
llm_model: str = ""
admin_telegram_ids: str = ""
admin_bootstrap_token: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@@ -36,6 +41,16 @@ class Settings(BaseSettings):
def is_production(self) -> bool:
return self.app_env.lower() == "production"
@property
def admin_telegram_id_list(self) -> list[int]:
values: list[int] = []
for item in self.admin_telegram_ids.split(","):
item = item.strip()
if not item:
continue
values.append(int(item))
return values
def validate_webapp_url_for_telegram(self) -> None:
url = self.effective_webapp_url
if self.is_production and not url.startswith("https://"):
@@ -44,6 +59,25 @@ class Settings(BaseSettings):
if self.is_production and any(item in url for item in forbidden):
raise RuntimeError("Telegram Mini App URL must not use localhost, internal IP, or http://")
def validate_production_settings(self) -> None:
if not self.is_production:
return
if self.allow_dev_auth:
raise RuntimeError("ALLOW_DEV_AUTH must be false in production")
if not self.bot_token or self.bot_token == "change-me":
raise RuntimeError("BOT_TOKEN is required in production")
if not self.internal_api_token or self.internal_api_token.startswith("change-"):
raise RuntimeError("INTERNAL_API_TOKEN must be a real secret in production")
if not self.secret_key or self.secret_key.startswith("change-"):
raise RuntimeError("SECRET_KEY must be configured in production")
if not self.redis_url:
raise RuntimeError("REDIS_URL is required in production for rate limiting and queues")
if bool(self.vapid_public_key) != bool(self.vapid_private_key):
raise RuntimeError("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be configured together")
if not self.cors_origin_list:
raise RuntimeError("CORS_ORIGINS is required in production")
self.validate_webapp_url_for_telegram()
@lru_cache
def get_settings() -> Settings:

View File

@@ -1,6 +1,12 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from time import monotonic
from uuid import uuid4
from fastapi import Depends, FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import (
admin,
@@ -16,10 +22,54 @@ from app.api import (
service_visits,
sto_booking,
users,
work_orders,
)
from app.core.config import settings
from app.db.session import get_session
from app.services.rate_limit import get_redis_client
app = FastAPI(title="Drivers Bot API", version="0.1.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings.validate_production_settings()
yield
app = FastAPI(title="Drivers Bot API", version="0.1.0", lifespan=lifespan)
REQUEST_COUNT = 0
REQUEST_ERRORS = 0
REQUEST_DURATION_TOTAL = 0.0
@app.middleware("http")
async def production_headers_and_metrics(request: Request, call_next):
global REQUEST_COUNT, REQUEST_DURATION_TOTAL, REQUEST_ERRORS
request_id = request.headers.get("X-Request-ID") or str(uuid4())
start = monotonic()
try:
response = await call_next(request)
except Exception:
REQUEST_ERRORS += 1
raise
duration = monotonic() - start
REQUEST_COUNT += 1
REQUEST_DURATION_TOTAL += duration
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["X-Frame-Options"] = "DENY"
if settings.is_production:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self' https://telegram.org https://*.telegram.org; "
"connect-src 'self' https://api.telegram.org; "
"img-src 'self' data: https:; "
"script-src 'self' 'unsafe-inline' https://telegram.org https://*.telegram.org; "
"style-src 'self' 'unsafe-inline'; "
"frame-ancestors 'none'"
)
return response
dev_origins = ["http://localhost:8000", "http://127.0.0.1:8000"] if not settings.is_production else []
cors_origins = settings.cors_origin_list or dev_origins
@@ -43,6 +93,7 @@ app.include_router(parser.router, prefix="/api")
app.include_router(service_centers.router, prefix="/api")
app.include_router(sto_booking.router, prefix="/api")
app.include_router(service_visits.router, prefix="/api")
app.include_router(work_orders.router, prefix="/api")
app.include_router(change_requests.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
@@ -52,4 +103,41 @@ async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/metrics")
async def metrics() -> Response:
avg = REQUEST_DURATION_TOTAL / REQUEST_COUNT if REQUEST_COUNT else 0
body = "\n".join(
[
"# TYPE carpass_requests_total counter",
f"carpass_requests_total {REQUEST_COUNT}",
"# TYPE carpass_request_errors_total counter",
f"carpass_request_errors_total {REQUEST_ERRORS}",
"# TYPE carpass_request_duration_seconds_avg gauge",
f"carpass_request_duration_seconds_avg {avg:.6f}",
"",
]
)
return Response(body, media_type="text/plain; version=0.0.4")
@app.get("/ready")
async def ready(session: AsyncSession = Depends(get_session)) -> dict[str, str]:
await session.execute(text("select 1"))
migration = "unknown"
try:
version = await session.execute(text("select version_num from alembic_version limit 1"))
migration = version.scalar_one_or_none() or "unknown"
except Exception:
migration = "not_checked"
redis_status = "disabled"
if settings.redis_url:
redis = await get_redis_client()
if redis is None:
redis_status = "client_missing"
else:
await redis.ping()
redis_status = "ok"
return {"status": "ready", "database": "ok", "redis": redis_status, "migration": migration}
app.mount("/", StaticFiles(directory="web", html=True), name="web")

View File

@@ -264,6 +264,10 @@ class ServiceEmployee(Base):
role: Mapped[str] = mapped_column(String(32), default="receptionist", server_default="receptionist", index=True)
permissions: Mapped[dict | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
invite_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True)
invite_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
invite_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
service_center = relationship("ServiceCenter", back_populates="employees")
@@ -311,15 +315,35 @@ class ServiceVisit(Base):
__tablename__ = "service_visits"
id: Mapped[int] = mapped_column(primary_key=True)
work_order_number: Mapped[str | None] = mapped_column(String(40), unique=True, index=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
created_by_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
assigned_employee_id: Mapped[int | None] = mapped_column(ForeignKey("service_employees.id", ondelete="SET NULL"), index=True)
visit_date: Mapped[date] = mapped_column(Date, index=True)
odometer: Mapped[int | None]
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
customer_complaint: Mapped[str | None] = mapped_column(Text)
diagnosis: Mapped[str | None] = mapped_column(Text)
notes: Mapped[str | None] = mapped_column(Text)
service_comment: Mapped[str | None] = mapped_column(Text)
owner_comment: Mapped[str | None] = mapped_column(Text)
recommendations_text: Mapped[str | None] = mapped_column(Text)
attachment_urls: Mapped[list | None] = mapped_column(JSON)
total_cost: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
labor_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
product_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
discount_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
final_total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
price_approved_total: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
approval_required: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
completed_snapshot: Mapped[dict | None] = mapped_column(JSON)
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
owner_resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
@@ -328,6 +352,9 @@ class ServiceVisit(Base):
service_center = relationship("ServiceCenter", back_populates="visits")
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan")
product_items = relationship("ServiceProductItem", back_populates="visit", cascade="all, delete-orphan")
status_history = relationship("WorkOrderStatusHistory", back_populates="visit", cascade="all, delete-orphan")
corrections = relationship("WorkOrderCorrection", back_populates="visit", cascade="all, delete-orphan")
class MaintenanceRecommendation(Base):
@@ -395,7 +422,12 @@ class ServiceNotification(Base):
notification_type: Mapped[str] = mapped_column(String(80), index=True)
title: Mapped[str] = mapped_column(String(180))
body: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
retry_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
last_error: Mapped[str | None] = mapped_column(Text)
idempotency_key: Mapped[str | None] = mapped_column(String(160), unique=True, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
@@ -406,7 +438,13 @@ class ServiceWorkItem(Base):
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
work_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
title: Mapped[str] = mapped_column(String(180))
category: Mapped[str | None] = mapped_column(String(80))
description: Mapped[str | None] = mapped_column(Text)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1")
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
discount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
total: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
parts: Mapped[list | None] = mapped_column(JSON)
oil_brand: Mapped[str | None] = mapped_column(String(80))
oil_viscosity: Mapped[str | None] = mapped_column(String(40))
@@ -414,11 +452,85 @@ class ServiceWorkItem(Base):
next_due_odometer: Mapped[int | None]
next_due_date: Mapped[date | None] = mapped_column(Date)
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
warranty_days: Mapped[int | None] = mapped_column(Integer)
warranty_odometer_km: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
visit = relationship("ServiceVisit", back_populates="work_items")
class ServiceProductItem(Base):
__tablename__ = "service_product_items"
id: Mapped[int] = mapped_column(primary_key=True)
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(180))
category: Mapped[str | None] = mapped_column(String(80), index=True)
product_type: Mapped[str] = mapped_column(String(40), default="other", server_default="other", index=True)
brand: Mapped[str | None] = mapped_column(String(80))
sku: Mapped[str | None] = mapped_column(String(120))
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1")
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
discount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
total: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
viscosity: Mapped[str | None] = mapped_column(String(40))
specification: Mapped[str | None] = mapped_column(String(120))
used_volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
visit = relationship("ServiceVisit", back_populates="product_items")
class WorkOrderStatusHistory(Base):
__tablename__ = "work_order_status_history"
id: Mapped[int] = mapped_column(primary_key=True)
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
from_status: Mapped[str | None] = mapped_column(String(40))
to_status: Mapped[str] = mapped_column(String(40), index=True)
changed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
comment: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
visit = relationship("ServiceVisit", back_populates="status_history")
class WorkOrderCorrection(Base):
__tablename__ = "work_order_corrections"
id: Mapped[int] = mapped_column(primary_key=True)
service_visit_id: Mapped[int] = mapped_column(ForeignKey("service_visits.id", ondelete="CASCADE"), index=True)
requested_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
reason: Mapped[str] = mapped_column(Text)
proposed_changes: Mapped[dict | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(24), default="pending", server_default="pending", index=True)
owner_approval_required: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
created_version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
visit = relationship("ServiceVisit", back_populates="corrections")
class InventoryTransaction(Base):
__tablename__ = "inventory_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
product_item_id: Mapped[int | None] = mapped_column(ForeignKey("service_product_items.id", ondelete="SET NULL"), index=True)
transaction_type: Mapped[str] = mapped_column(String(32), index=True)
sku: Mapped[str | None] = mapped_column(String(120), index=True)
title: Mapped[str | None] = mapped_column(String(180))
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=0, server_default="0")
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), index=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
class ServiceCenterReview(Base):
__tablename__ = "service_center_reviews"
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)

View File

@@ -66,6 +66,7 @@ class ServiceEntry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
entry_date: Mapped[date] = mapped_column(Date, index=True)
odometer: Mapped[int | None]
service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
@@ -86,6 +87,7 @@ class ExpenseEntry(Base):
id: Mapped[int] = mapped_column(primary_key=True)
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
service_visit_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
entry_date: Mapped[date] = mapped_column(Date, index=True)
category: Mapped[ExpenseCategory] = mapped_column(Enum(ExpenseCategory), index=True)
title: Mapped[str] = mapped_column(String(180))

View File

@@ -30,6 +30,8 @@ class ServiceCenterRead(ServiceCenterCreate):
name: str
verification_status: str
owner_user_id: int | None = None
employee_role: str | None = None
employee_status: str | None = None
created_at: datetime
verified_at: datetime | None = None
suspended_at: datetime | None = None
@@ -78,6 +80,7 @@ class ServiceEmployeeInvite(BaseModel):
telegram_id: int
role: str = "receptionist"
permissions: dict | None = None
expires_in_hours: int = Field(default=72, ge=0, le=720)
class ServiceEmployeeRead(BaseModel):
@@ -87,6 +90,10 @@ class ServiceEmployeeRead(BaseModel):
role: str
permissions: dict | None = None
status: str
invite_token: str | None = None
invite_expires_at: datetime | None = None
invite_revoked_at: datetime | None = None
activated_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@@ -284,8 +291,27 @@ class ServiceVisitCreate(BaseModel):
class ServiceVisitRead(ServiceVisitCreate):
id: int
service_center_id: int
work_order_number: str | None = None
owner_id: int | None = None
created_by_employee_id: int | None = None
assigned_employee_id: int | None = None
status: str
customer_complaint: str | None = None
diagnosis: str | None = None
service_comment: str | None = None
owner_comment: str | None = None
recommendations_text: str | None = None
attachment_urls: list[str] | None = None
labor_total: Decimal = Decimal("0")
product_total: Decimal = Decimal("0")
discount_total: Decimal = Decimal("0")
final_total: Decimal = Decimal("0")
approval_required: bool = False
version: int = 1
completed_snapshot: dict | None = None
opened_at: datetime | None = None
approved_at: datetime | None = None
completed_at: datetime | None = None
owner_resolved_at: datetime | None = None
created_at: datetime
@@ -295,7 +321,12 @@ class ServiceVisitRead(ServiceVisitCreate):
class ServiceWorkItemCreate(BaseModel):
work_type: str = "other"
title: str
category: str | None = None
description: str | None = None
quantity: Decimal = Decimal("1")
unit: str = "pcs"
unit_price: Decimal | None = None
discount: Decimal = Decimal("0")
parts: list[dict] | None = None
oil_brand: str | None = None
oil_viscosity: str | None = None
@@ -303,11 +334,108 @@ class ServiceWorkItemCreate(BaseModel):
next_due_odometer: int | None = None
next_due_date: date | None = None
price: Decimal | None = None
warranty_days: int | None = None
warranty_odometer_km: int | None = None
@model_validator(mode="after")
def validate_item(self) -> "ServiceWorkItemCreate":
if self.quantity <= 0:
raise ValueError("quantity must be positive")
if self.discount < 0:
raise ValueError("discount must be non-negative")
if self.unit_price is not None and self.unit_price < 0:
raise ValueError("unit_price must be non-negative")
if self.price is not None and self.price < 0:
raise ValueError("price must be non-negative")
return self
class ServiceWorkItemRead(ServiceWorkItemCreate):
id: int
service_visit_id: int
total: Decimal | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ServiceProductItemCreate(BaseModel):
title: str
category: str | None = None
product_type: str = "other"
brand: str | None = None
sku: str | None = None
quantity: Decimal = Decimal("1")
unit: str = "pcs"
unit_price: Decimal = Decimal("0")
discount: Decimal = Decimal("0")
volume: Decimal | None = None
viscosity: str | None = None
specification: str | None = None
used_volume: Decimal | None = None
@model_validator(mode="after")
def validate_product(self) -> "ServiceProductItemCreate":
if self.quantity <= 0:
raise ValueError("quantity must be positive")
if self.unit_price < 0 or self.discount < 0:
raise ValueError("price and discount must be non-negative")
return self
class ServiceProductItemRead(ServiceProductItemCreate):
id: int
service_visit_id: int
total: Decimal
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkOrderUpdate(BaseModel):
odometer: int | None = None
assigned_employee_id: int | None = None
customer_complaint: str | None = None
diagnosis: str | None = None
notes: str | None = None
service_comment: str | None = None
owner_comment: str | None = None
recommendations_text: str | None = None
attachment_urls: list[str] | None = None
discount_total: Decimal | None = None
approval_required: bool | None = None
class WorkOrderDecision(BaseModel):
comment: str | None = None
confirm_lower_odometer: bool = False
class WorkOrderStatusHistoryRead(BaseModel):
id: int
service_visit_id: int
from_status: str | None = None
to_status: str
changed_by_user_id: int | None = None
comment: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkOrderCorrectionCreate(BaseModel):
reason: str = Field(min_length=3, max_length=4000)
proposed_changes: dict | None = None
owner_approval_required: bool = True
class WorkOrderCorrectionRead(WorkOrderCorrectionCreate):
id: int
service_visit_id: int
requested_by_user_id: int | None = None
status: str
created_version: int
resolved_at: datetime | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)

View File

@@ -6,10 +6,14 @@ APPOINTMENT_STATUSES = {
"draft",
"requested",
"confirmed",
"confirmed_by_sto",
"proposed_new_time",
"rejected",
"rejected_by_sto",
"cancelled_by_owner",
"cancelled_by_customer",
"cancelled_by_sto",
"converted_to_work_order",
"completed",
"no_show",
}

View File

@@ -260,6 +260,7 @@ async def expense_period_totals(
.where(
ExpenseEntry.car_id == car_id,
ExpenseEntry.entry_date <= date_to,
ExpenseEntry.service_visit_id.is_(None),
)
.order_by(ExpenseEntry.entry_date.asc(), ExpenseEntry.id.asc())
)

View File

@@ -1,27 +1,70 @@
from datetime import UTC, datetime, timedelta
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.car import ServiceNotification
from app.models.user import User
MODERATOR_ROLES = {"admin", "verifier", "moderator"}
async def notify_user(user: User, text: str) -> None:
async def notify_user(user: User, text: str) -> bool:
if not settings.bot_token or settings.app_env == "test":
return
return False
try:
async with httpx.AsyncClient(timeout=5) as client:
await client.post(
response = await client.post(
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
data={"chat_id": str(user.telegram_id), "text": text},
)
return response.status_code < 400
except Exception:
return
return False
async def notify_platform_moderators(session: AsyncSession, text: str) -> None:
result = await session.execute(select(User).where(User.platform_role.in_(MODERATOR_ROLES)))
for user in result.scalars():
await notify_user(user, text)
async def retry_failed_notifications(session: AsyncSession, *, limit: int = 50) -> int:
return await process_notification_queue(session, limit=limit)
async def process_notification_queue(session: AsyncSession, *, limit: int = 50) -> int:
now = datetime.now(UTC)
result = await session.execute(
select(ServiceNotification)
.where(
ServiceNotification.status.in_(["pending", "failed", "retrying"]),
ServiceNotification.retry_count < 5,
)
.order_by(ServiceNotification.created_at.asc())
.limit(limit)
)
delivered = 0
for notification in result.scalars():
if notification.status == "retrying" and notification.created_at > now - timedelta(seconds=30):
continue
notification.status = "processing"
user = await session.get(User, notification.recipient_user_id)
if user is None:
notification.status = "abandoned"
notification.last_error = "recipient_not_found"
continue
ok = await notify_user(user, f"{notification.title}\n{notification.body}" if notification.body else notification.title)
notification.retry_count += 1
if ok:
notification.status = "sent"
notification.sent_at = datetime.now(UTC)
notification.last_error = None
delivered += 1
else:
notification.status = "abandoned" if notification.retry_count >= 5 else "retrying"
notification.last_error = "telegram_delivery_failed"
await session.commit()
return delivered

View File

@@ -50,17 +50,35 @@ class TesseractOCRProvider:
def _recognize_sync(self, content: bytes) -> str:
try:
import pytesseract
from PIL import Image
from PIL import Image, ImageEnhance, ImageOps
except ImportError:
return ""
try:
image = Image.open(BytesIO(content))
except Exception:
return ""
candidates = [image]
try:
return pytesseract.image_to_string(image, lang=settings.ocr_languages)
grayscale = ImageOps.grayscale(image)
resized = grayscale.resize((grayscale.width * 2, grayscale.height * 2))
contrast = ImageEnhance.Contrast(resized).enhance(1.8)
threshold = contrast.point(lambda pixel: 255 if pixel > 165 else 0)
candidates.extend([grayscale, contrast, threshold])
except Exception:
return pytesseract.image_to_string(image)
candidates = [image]
recognized: list[str] = []
for candidate in candidates:
for config in ("--psm 6", "--psm 11"):
try:
text = pytesseract.image_to_string(candidate, lang=settings.ocr_languages, config=config)
except Exception:
try:
text = pytesseract.image_to_string(candidate, config=config)
except Exception:
text = ""
if text.strip():
recognized.append(text)
return "\n".join(recognized)
class CompositeOCRProvider:

124
app/services/rate_limit.py Normal file
View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import time
from collections import defaultdict, deque
from collections.abc import Hashable
from fastapi import HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.user import User
BucketKey = tuple[str, Hashable]
_buckets: dict[BucketKey, deque[float]] = defaultdict(deque)
_redis_client = None
def reset_rate_limit_state() -> None:
_buckets.clear()
async def check_rate_limit(
*,
scope: str,
limit: int,
window_seconds: int,
request: Request | None = None,
user: User | None = None,
session: AsyncSession | None = None,
) -> None:
identifiers: list[Hashable] = []
if user is not None:
identifiers.append(f"user:{user.id}")
identifiers.append(f"telegram:{user.telegram_id}")
if request is not None and request.client is not None:
identifiers.append(f"ip:{request.client.host}")
if not identifiers:
identifiers.append("anonymous")
if settings.redis_url:
allowed = await check_redis_rate_limit(scope, identifiers, limit, window_seconds)
if not allowed:
await log_rate_limit_event(session, scope=scope, identifier="redis")
raise_rate_limit(scope, window_seconds)
return
now = time.monotonic()
for identifier in identifiers:
key = (scope, identifier)
bucket = _buckets[key]
while bucket and now - bucket[0] > window_seconds:
bucket.popleft()
if len(bucket) >= limit:
await log_rate_limit_event(session, scope=scope, identifier=str(identifier))
raise_rate_limit(scope, window_seconds)
for identifier in identifiers:
_buckets[(scope, identifier)].append(now)
def raise_rate_limit(scope: str, window_seconds: int) -> None:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"code": "rate_limit_exceeded",
"message": "Слишком много запросов. Попробуйте чуть позже.",
"scope": scope,
"retry_after_seconds": window_seconds,
},
)
async def get_redis_client():
global _redis_client
if _redis_client is not None:
return _redis_client
try:
from redis.asyncio import Redis
except ImportError:
return None
_redis_client = Redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=True)
return _redis_client
async def check_redis_rate_limit(
scope: str,
identifiers: list[Hashable],
limit: int,
window_seconds: int,
) -> bool:
client = await get_redis_client()
if client is None:
return True
now_window = int(time.time() // window_seconds)
keys = [f"rl:{scope}:{identifier}:{now_window}" for identifier in identifiers]
pipe = client.pipeline()
for key in keys:
pipe.incr(key)
pipe.expire(key, window_seconds * 2)
results = await pipe.execute()
counts = [int(results[index]) for index in range(0, len(results), 2)]
return all(count <= limit for count in counts)
async def log_rate_limit_event(
session: AsyncSession | None,
*,
scope: str,
identifier: str,
) -> None:
if session is None:
return
from app.models.car import AuditLog
session.add(
AuditLog(
actor_user_id=None,
actor_role="system",
action="rate_limit.exceeded",
target_type=scope,
target_id=identifier[:80],
metadata_json={"scope": scope, "identifier": identifier},
)
)

View File

@@ -21,7 +21,7 @@ from app.models.expense import ServiceEntry
from app.models.user import User
from app.services.notifications import notify_user
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "proposed_new_time"}
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "confirmed_by_sto", "proposed_new_time"}
DEFAULT_SERVICE_DURATIONS = {
"oil_change": 60,
"diagnostics": 60,
@@ -190,7 +190,16 @@ async def create_service_notification(
service_center_id: int | None = None,
appointment_id: int | None = None,
send_telegram: bool = True,
idempotency_key: str | None = None,
) -> ServiceNotification:
if idempotency_key:
existing = (
await session.execute(
select(ServiceNotification).where(ServiceNotification.idempotency_key == idempotency_key)
)
).scalar_one_or_none()
if existing is not None:
return existing
notification = ServiceNotification(
recipient_user_id=recipient_user_id,
service_center_id=service_center_id,
@@ -198,12 +207,21 @@ async def create_service_notification(
notification_type=notification_type,
title=title,
body=body,
idempotency_key=idempotency_key,
)
session.add(notification)
if send_telegram:
user = await session.get(User, recipient_user_id)
if user is not None:
await notify_user(user, f"{title}\n{body}" if body else title)
notification.status = "processing"
delivered = await notify_user(user, f"{title}\n{body}" if body else title)
if delivered:
notification.status = "sent"
notification.sent_at = datetime.now(UTC)
else:
notification.status = "retrying"
notification.retry_count = 1
notification.last_error = "telegram_delivery_failed"
return notification

54
app/services/uploads.py Normal file
View File

@@ -0,0 +1,54 @@
from __future__ import annotations
import mimetypes
from pathlib import PurePath
from fastapi import HTTPException
SAFE_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}
SAFE_TEXT_TYPES = {"text/plain", "application/pdf"}
BLOCKED_EXTENSIONS = {".exe", ".bat", ".cmd", ".sh", ".php", ".js", ".html", ".svg"}
def sanitize_filename(filename: str | None) -> str:
name = PurePath(filename or "upload.bin").name
return "".join(char if char.isalnum() or char in {".", "-", "_"} else "_" for char in name)[:160]
def validate_upload(
*,
content: bytes,
filename: str | None,
content_type: str | None,
max_bytes: int,
allowed_types: set[str],
) -> str:
safe_name = sanitize_filename(filename)
suffix = PurePath(safe_name).suffix.lower()
if len(content) > max_bytes:
raise HTTPException(status_code=413, detail="File is too large")
if suffix in BLOCKED_EXTENSIONS:
raise HTTPException(status_code=415, detail="Executable or unsafe file type is not allowed")
detected_type = (content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream").lower()
if detected_type not in allowed_types:
raise HTTPException(status_code=415, detail="Unsupported file type")
if detected_type in SAFE_IMAGE_TYPES:
validate_image(content)
return safe_name
def validate_image(content: bytes) -> None:
try:
from PIL import Image
except ImportError:
return
try:
with Image.open(__import__("io").BytesIO(content)) as image:
width, height = image.size
if width * height > 24_000_000:
raise HTTPException(status_code=413, detail="Image dimensions are too large")
image.verify()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=415, detail="Corrupted image file") from exc

315
app/services/work_orders.py Normal file
View File

@@ -0,0 +1,315 @@
from __future__ import annotations
from datetime import UTC, date, datetime
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.car import (
Car,
InventoryTransaction,
MaintenanceRecommendation,
ServiceAppointment,
ServiceCenter,
ServiceProductItem,
ServiceVisit,
ServiceWorkItem,
WorkOrderStatusHistory,
)
from app.models.expense import ExpenseCategory, ExpenseEntry, ServiceEntry, ServiceType
from app.models.user import User
from app.services.odometer import apply_odometer_from_record, validate_odometer_change
from app.services.sto_booking import create_service_notification
WORK_ORDER_STATUSES = {
"draft",
"diagnosis",
"waiting_owner_approval",
"approved_by_owner",
"rejected_by_owner",
"in_progress",
"completed",
"cancelled",
"archived",
}
LOCKED_WORK_ORDER_STATUSES = {"completed", "cancelled", "archived"}
def money(value: Decimal | int | float | None) -> Decimal:
return Decimal(str(value or 0)).quantize(Decimal("0.01"))
def line_total(quantity: Decimal, unit_price: Decimal | None, discount: Decimal) -> Decimal:
return max(Decimal("0"), Decimal(quantity) * money(unit_price) - money(discount)).quantize(Decimal("0.01"))
async def add_status_history(
session: AsyncSession,
visit: ServiceVisit,
*,
to_status: str,
actor: User | None,
comment: str | None = None,
) -> None:
if to_status not in WORK_ORDER_STATUSES and to_status not in {"pending_owner_confirmation", "confirmed", "disputed"}:
raise HTTPException(status_code=400, detail="Unsupported work order status")
from_status = visit.status
if from_status == to_status:
return
visit.status = to_status
session.add(
WorkOrderStatusHistory(
service_visit_id=visit.id,
from_status=from_status,
to_status=to_status,
changed_by_user_id=actor.id if actor else None,
comment=comment,
)
)
async def ensure_work_order_editable(visit: ServiceVisit) -> None:
if visit.status in LOCKED_WORK_ORDER_STATUSES:
raise HTTPException(status_code=409, detail="Completed or archived work order cannot be changed")
async def refresh_work_order_totals(session: AsyncSession, visit: ServiceVisit) -> None:
work_items = list(
(
await session.execute(
select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id)
)
).scalars()
)
product_items = list(
(
await session.execute(
select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id)
)
).scalars()
)
labor_total = sum((money(item.total if item.total is not None else item.price) for item in work_items), Decimal("0"))
product_total = sum((money(item.total) for item in product_items), Decimal("0"))
discount_total = money(visit.discount_total)
final_total = max(Decimal("0"), labor_total + product_total - discount_total).quantize(Decimal("0.01"))
visit.labor_total = labor_total.quantize(Decimal("0.01"))
visit.product_total = product_total.quantize(Decimal("0.01"))
visit.final_total = final_total
visit.total_cost = final_total
if visit.status == "approved_by_owner" and visit.price_approved_total is not None and final_total != visit.price_approved_total:
visit.status = "waiting_owner_approval"
visit.approved_at = None
async def assign_work_order_number(session: AsyncSession, visit: ServiceVisit) -> None:
if visit.work_order_number:
return
await session.flush()
visit.work_order_number = f"WO-{date.today():%Y%m%d}-{visit.id:06d}"
async def add_labor_item(
session: AsyncSession,
visit: ServiceVisit,
*,
payload: dict,
) -> ServiceWorkItem:
await ensure_work_order_editable(visit)
quantity = Decimal(str(payload.get("quantity") or 1))
unit_price = payload.get("unit_price")
legacy_price = payload.get("price")
total = line_total(quantity, money(unit_price if unit_price is not None else legacy_price), Decimal(str(payload.get("discount") or 0)))
item = ServiceWorkItem(**payload, service_visit_id=visit.id, total=total)
if item.price is None:
item.price = total
session.add(item)
await session.flush()
await refresh_work_order_totals(session, visit)
return item
async def add_product_item(
session: AsyncSession,
visit: ServiceVisit,
*,
payload: dict,
) -> ServiceProductItem:
await ensure_work_order_editable(visit)
quantity = Decimal(str(payload.get("quantity") or 1))
unit_price = Decimal(str(payload.get("unit_price") or 0))
discount = Decimal(str(payload.get("discount") or 0))
item = ServiceProductItem(**payload, service_visit_id=visit.id, total=line_total(quantity, unit_price, discount))
session.add(item)
await session.flush()
await refresh_work_order_totals(session, visit)
return item
async def close_work_order(
session: AsyncSession,
visit: ServiceVisit,
*,
actor: User,
confirm_lower_odometer: bool = False,
) -> tuple[ServiceEntry, ExpenseEntry]:
if visit.status == "completed":
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 service, expense
raise HTTPException(status_code=409, detail="Completed work order is missing immutable records")
if visit.status not in {"approved_by_owner", "in_progress", "diagnosis", "draft"}:
raise HTTPException(status_code=409, detail="Work order must be approved or in progress before completion")
if visit.approval_required and visit.status != "approved_by_owner":
raise HTTPException(status_code=409, detail="Owner approval is required before completion")
vehicle = await session.get(Car, visit.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
owner = await session.get(User, visit.owner_id or vehicle.owner_id)
if owner is None:
raise HTTPException(status_code=404, detail="Vehicle owner not found")
validate_odometer_change(
vehicle,
visit.odometer,
source_record_type="work_order",
confirm_lower_odometer=confirm_lower_odometer,
)
await refresh_work_order_totals(session, visit)
existing_service = (
await session.execute(select(ServiceEntry).where(ServiceEntry.service_visit_id == visit.id))
).scalar_one_or_none()
existing_expense = (
await session.execute(select(ExpenseEntry).where(ExpenseEntry.service_visit_id == visit.id))
).scalar_one_or_none()
if existing_service is not None or existing_expense is not None:
raise HTTPException(status_code=409, detail="Work order completion records already exist")
center = await session.get(ServiceCenter, visit.service_center_id)
vendor_name = center.display_name or center.name if center else None
service = ServiceEntry(
car_id=vehicle.id,
service_visit_id=visit.id,
entry_date=visit.visit_date,
odometer=visit.odometer,
service_type=ServiceType.maintenance,
title=f"Заказ-наряд {visit.work_order_number or visit.id}",
category="sto_work_order",
vendor=vendor_name,
total_cost=visit.final_total,
notes=visit.service_comment or visit.notes,
)
expense = ExpenseEntry(
car_id=vehicle.id,
service_visit_id=visit.id,
entry_date=visit.visit_date,
category=ExpenseCategory.maintenance,
title=f"СТО: заказ-наряд {visit.work_order_number or visit.id}",
vendor=vendor_name,
total_cost=max(visit.final_total, Decimal("0.01")),
currency=visit.currency,
odometer=visit.odometer,
metadata_json={
"service_visit_id": visit.id,
"work_order_number": visit.work_order_number,
"labor_total": str(visit.labor_total),
"product_total": str(visit.product_total),
},
)
session.add_all([service, expense])
await session.flush()
await apply_odometer_from_record(
session,
vehicle,
new_odometer=visit.odometer,
source_record_type="work_order",
source_record_id=visit.id,
changed_by=actor.id,
confirm_lower_odometer=confirm_lower_odometer,
)
visit.completed_at = datetime.now(UTC)
await add_status_history(session, visit, to_status="completed", actor=actor, comment="Work order completed")
appointment = (
await session.execute(
select(ServiceAppointment).where(ServiceAppointment.linked_work_order_id == visit.id)
)
).scalar_one_or_none()
if appointment is not None:
appointment.status = "completed"
if appointment and appointment.source_recommendation_id:
recommendation = await session.get(MaintenanceRecommendation, appointment.source_recommendation_id)
if recommendation is not None:
recommendation.status = "completed"
work_items = list(
(
await session.execute(
select(ServiceWorkItem).where(ServiceWorkItem.service_visit_id == visit.id)
)
).scalars()
)
product_items = list(
(
await session.execute(
select(ServiceProductItem).where(ServiceProductItem.service_visit_id == visit.id)
)
).scalars()
)
for product in product_items:
session.add(
InventoryTransaction(
service_center_id=visit.service_center_id,
service_visit_id=visit.id,
product_item_id=product.id,
transaction_type="consume",
sku=product.sku,
title=product.title,
quantity=product.quantity,
unit=product.unit,
actor_user_id=actor.id,
metadata_json={"source": "work_order_completion"},
)
)
for item in work_items:
if item.next_due_date or item.next_due_odometer:
session.add(
MaintenanceRecommendation(
vehicle_id=vehicle.id,
recommendation_type=item.work_type or "maintenance",
title=f"Следующее ТО: {item.title}",
due_odometer_km=item.next_due_odometer,
due_date=item.next_due_date,
priority="medium",
status="active",
source="work_order",
source_service_center_id=visit.service_center_id,
source_appointment_id=appointment.id if appointment else None,
)
)
visit.version = (visit.version or 1) + 1
visit.completed_snapshot = {
"work_order_number": visit.work_order_number,
"vehicle_id": vehicle.id,
"service_center_id": visit.service_center_id,
"odometer": visit.odometer,
"labor_total": str(visit.labor_total),
"product_total": str(visit.product_total),
"discount_total": str(visit.discount_total),
"final_total": str(visit.final_total),
"currency": visit.currency,
"completed_at": visit.completed_at.isoformat() if visit.completed_at else None,
}
await create_service_notification(
session,
recipient_user_id=owner.id,
service_center_id=visit.service_center_id,
appointment_id=appointment.id if appointment else None,
notification_type="work_order.completed",
title="Работа по заказ-наряду завершена",
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.",
idempotency_key=f"work_order:{visit.id}:completed",
)
return service, expense