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())
|
||||
Reference in New Issue
Block a user