Mechanic's work place
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:04:56 +09:00
parent fec9635079
commit 83ad880b9d
39 changed files with 2951 additions and 74 deletions

View File

@@ -1,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)