This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
337
app/api/work_orders.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
92
app/main.py
92
app/main.py
@@ -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")
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
124
app/services/rate_limit.py
Normal 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},
|
||||
)
|
||||
)
|
||||
@@ -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
54
app/services/uploads.py
Normal 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
315
app/services/work_orders.py
Normal 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
|
||||
Reference in New Issue
Block a user