Files
drivers_bot/app/api/sto_booking.py
VPN SaaS Dev ecfb5aa949
Some checks failed
ci / test (push) Has been cancelled
Refactor menu flows into dedicated pages
2026-05-16 11:59:09 +09:00

806 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import UTC, date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import func, 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,
MaintenanceRecommendation,
ServiceAppointment,
ServiceCenter,
ServiceCenterBookingSettings,
ServiceCenterHoliday,
ServiceVisit,
)
from app.models.user import User
from app.schemas.service_center import ServiceVisitRead
from app.schemas.sto_booking import (
AppointmentCancel,
AppointmentCreate,
AppointmentCreateWorkOrder,
AppointmentDecision,
AppointmentProposeTime,
AppointmentRead,
AvailableSlotRead,
MaintenanceRecommendationBook,
MaintenanceRecommendationCreate,
MaintenanceRecommendationRead,
ServiceCatalogItem,
ServiceCenterBookingSettingsRead,
ServiceCenterBookingSettingsUpsert,
ServiceCenterHolidayCreate,
ServiceCenterHolidayRead,
STODashboardRead,
)
from app.services.rate_limit import check_rate_limit
from app.services.sto_booking import (
calculate_available_slots,
create_service_notification,
ensure_oil_recommendation,
ensure_slot_available,
estimate_duration,
get_booking_settings,
money_to_float,
notify_service_staff,
)
from app.services.work_orders import add_status_history, assign_work_order_number
APPROVED_SERVICE_STATUSES = {"verified", "approved"}
router = APIRouter(tags=["sto-booking"])
def _utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
async def _approved_service_center(session: AsyncSession, service_center_id: int) -> ServiceCenter:
center = await session.get(ServiceCenter, service_center_id)
if center is None or center.verification_status not in APPROVED_SERVICE_STATUSES:
raise HTTPException(status_code=404, detail="Service center not found")
return center
async def _owned_vehicle(session: AsyncSession, vehicle_id: int, user: User) -> Car:
vehicle = await session.get(Car, 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 _appointment_for_owner(session: AsyncSession, appointment_id: int, user: User) -> ServiceAppointment:
appointment = await session.get(ServiceAppointment, appointment_id)
if appointment is None:
raise HTTPException(status_code=404, detail="Appointment not found")
if appointment.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return appointment
async def _appointment_for_sto(session: AsyncSession, appointment_id: int, user: User) -> ServiceAppointment:
appointment = await session.get(ServiceAppointment, appointment_id)
if appointment is None:
raise HTTPException(status_code=404, detail="Appointment not found")
await ensure_service_employee(session, appointment.service_center_id, user, {"owner", "manager", "receptionist"})
return appointment
def _ensure_appointment_deletable(appointment: ServiceAppointment) -> None:
if appointment.linked_work_order_id or appointment.status in {"converted_to_work_order", "completed"}:
raise HTTPException(status_code=409, detail="Appointment already has a work order and cannot be deleted")
async def _restore_recommendation_after_appointment_delete(
session: AsyncSession,
appointment: ServiceAppointment,
) -> None:
if not appointment.source_recommendation_id:
return
recommendation = await session.get(MaintenanceRecommendation, appointment.source_recommendation_id)
if recommendation is not None and recommendation.status == "booked":
recommendation.status = "active"
recommendation.source_appointment_id = None
@router.get("/sto/catalog", response_model=list[ServiceCatalogItem])
async def get_sto_catalog(
city: str | None = None,
specialization: str | None = None,
open_today: bool = False,
has_slots: bool = False,
session: AsyncSession = Depends(get_session),
) -> list[ServiceCatalogItem]:
stmt = select(ServiceCenter).where(ServiceCenter.verification_status.in_(APPROVED_SERVICE_STATUSES))
if city:
stmt = stmt.where(ServiceCenter.city.ilike(f"%{city}%"))
if specialization:
stmt = stmt.where(ServiceCenter.specializations.is_not(None))
stmt = stmt.order_by(ServiceCenter.rating_avg.desc().nullslast(), ServiceCenter.display_name.asc())
centers = list((await session.execute(stmt)).scalars())
today = date.today()
result: list[ServiceCatalogItem] = []
for center in centers:
settings = await get_booking_settings(session, center.id)
if open_today and today.weekday() not in (settings.working_days or []):
continue
if specialization and specialization.lower() not in " ".join(center.specializations or []).lower():
continue
nearest_slot_at = None
if has_slots or settings.accepts_online_booking:
slots = await calculate_available_slots(
session,
service_center_id=center.id,
date_from=today,
date_to=today + timedelta(days=14),
duration_minutes=60,
limit=1,
)
nearest_slot_at = slots[0][0] if slots else None
if has_slots and nearest_slot_at is None:
continue
result.append(
ServiceCatalogItem(
id=center.id,
display_name=center.display_name,
name=center.name,
city=center.city,
address=center.address,
specializations=center.specializations,
working_hours=center.working_hours,
rating_avg=float(center.rating_avg) if center.rating_avg is not None else None,
reviews_count=center.reviews_count,
nearest_slot_at=nearest_slot_at,
accepts_online_booking=settings.accepts_online_booking,
)
)
return result
@router.get("/sto/{service_center_id}/available-slots", response_model=list[AvailableSlotRead])
async def get_available_slots(
service_center_id: int,
service_type: str = "maintenance",
date_from: date | None = None,
date_to: date | None = None,
duration_minutes: int | None = Query(default=None, ge=10, le=1440),
session: AsyncSession = Depends(get_session),
) -> list[AvailableSlotRead]:
await _approved_service_center(session, service_center_id)
start_date = date_from or date.today()
end_date = date_to or start_date + timedelta(days=14)
if end_date < start_date:
raise HTTPException(status_code=400, detail="date_to must be after date_from")
duration = estimate_duration(service_type, duration_minutes)
slots = await calculate_available_slots(
session,
service_center_id=service_center_id,
date_from=start_date,
date_to=end_date,
duration_minutes=duration,
)
return [AvailableSlotRead(start_at=start, end_at=end) for start, end in 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)
start_at = _utc(payload.requested_start_at)
end_at = await ensure_slot_available(
session,
service_center_id=payload.service_center_id,
start_at=start_at,
duration_minutes=duration,
)
if payload.source_recommendation_id:
recommendation = await session.get(MaintenanceRecommendation, payload.source_recommendation_id)
if recommendation is None or recommendation.vehicle_id != vehicle.id:
raise HTTPException(status_code=400, detail="Recommendation does not belong to vehicle")
appointment = ServiceAppointment(
service_center_id=payload.service_center_id,
vehicle_id=vehicle.id,
owner_id=current_user.id,
created_by=current_user.id,
service_type=payload.service_type,
service_name=payload.service_name,
requested_start_at=start_at,
requested_end_at=end_at,
estimated_duration_minutes=duration,
status="requested",
customer_comment=payload.customer_comment,
source_recommendation_id=payload.source_recommendation_id,
)
session.add(appointment)
await session.flush()
if payload.source_recommendation_id:
recommendation.status = "booked"
recommendation.source_appointment_id = appointment.id
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.requested",
title="Новая заявка на запись",
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
appointment_id=appointment.id,
)
await log_audit(
session,
actor=current_user,
action="appointment.create",
target_type="service_appointment",
target_id=appointment.id,
)
await session.commit()
await session.refresh(appointment)
return appointment
@router.get("/appointments/my", response_model=list[AppointmentRead])
async def get_my_appointments(
status_filter: str | None = Query(default=None, alias="status"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceAppointment]:
stmt = (
select(ServiceAppointment)
.where(ServiceAppointment.owner_id == current_user.id)
.order_by(ServiceAppointment.requested_start_at.desc())
)
if status_filter:
stmt = stmt.where(ServiceAppointment.status == status_filter)
return list((await session.execute(stmt)).scalars())
@router.post("/appointments/{appointment_id}/cancel", response_model=AppointmentRead)
async def cancel_appointment(
appointment_id: int,
payload: AppointmentCancel,
session: AsyncSession = Depends(get_session),
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_owner", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be cancelled")
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_owner",
title="Клиент отменил запись",
body=payload.reason,
appointment_id=appointment.id,
)
await log_audit(session, actor=current_user, action="appointment.cancel", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.delete("/appointments/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_appointment_by_owner(
appointment_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
appointment = await _appointment_for_owner(session, appointment_id, current_user)
_ensure_appointment_deletable(appointment)
await _restore_recommendation_after_appointment_delete(session, appointment)
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.deleted_by_owner",
title="Клиент удалил запись",
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
appointment_id=None,
)
await log_audit(
session,
actor=current_user,
action="appointment.delete_by_owner",
target_type="service_appointment",
target_id=appointment_id,
)
await session.delete(appointment)
await session.commit()
@router.post("/appointments/{appointment_id}/accept-proposed-time", response_model=AppointmentRead)
async def accept_proposed_time(
appointment_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_owner(session, appointment_id, current_user)
if appointment.status != "proposed_new_time" or not appointment.proposed_start_at or not appointment.proposed_end_at:
raise HTTPException(status_code=409, detail="Appointment has no proposed time")
await ensure_slot_available(
session,
service_center_id=appointment.service_center_id,
start_at=appointment.proposed_start_at,
duration_minutes=appointment.estimated_duration_minutes,
exclude_appointment_id=appointment.id,
)
appointment.confirmed_start_at = appointment.proposed_start_at
appointment.confirmed_end_at = appointment.proposed_end_at
appointment.requested_start_at = appointment.proposed_start_at
appointment.requested_end_at = appointment.proposed_end_at
appointment.status = "confirmed"
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.proposed_time_accepted",
title="Клиент принял новое время",
appointment_id=appointment.id,
)
await log_audit(session, actor=current_user, action="appointment.accept_proposed_time", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/appointments/{appointment_id}/reject-proposed-time", response_model=AppointmentRead)
async def reject_proposed_time(
appointment_id: int,
payload: AppointmentDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
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_by_sto"
appointment.service_center_comment = payload.comment
await notify_service_staff(
session,
service_center_id=appointment.service_center_id,
notification_type="appointment.proposed_time_rejected",
title="Клиент отклонил новое время",
body=payload.comment,
appointment_id=appointment.id,
)
await log_audit(session, actor=current_user, action="appointment.reject_proposed_time", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.get("/sto/dashboard", response_model=STODashboardRead)
async def get_sto_dashboard(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> STODashboardRead:
await ensure_service_employee(session, service_center_id, current_user)
center = await session.get(ServiceCenter, service_center_id)
if center is None:
raise HTTPException(status_code=404, detail="Service center not found")
month_start = date.today().replace(day=1)
connected_vehicles = int(
(await session.execute(select(func.count(CarServiceLink.id)).where(
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "approved",
CarServiceLink.is_active.is_(True),
))).scalar_one() or 0
)
pending_links = int(
(await session.execute(select(func.count(CarServiceLink.id)).where(
CarServiceLink.service_center_id == service_center_id,
CarServiceLink.status == "pending",
))).scalar_one() or 0
)
pending_appointments = int(
(await session.execute(select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.status == "requested",
))).scalar_one() or 0
)
confirmed_appointments = int(
(await session.execute(select(func.count(ServiceAppointment.id)).where(
ServiceAppointment.service_center_id == service_center_id,
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", "diagnosis", "waiting_owner_approval", "approved_by_owner", "in_progress", "pending_owner_confirmation"]),
))).scalar_one() or 0
)
completed_result = await session.execute(
select(func.count(ServiceVisit.id), func.coalesce(func.sum(ServiceVisit.total_cost), 0)).where(
ServiceVisit.service_center_id == service_center_id,
ServiceVisit.status == "confirmed",
ServiceVisit.visit_date >= month_start,
)
)
completed_count, revenue_month = completed_result.one()
average_check = money_to_float(revenue_month) / int(completed_count or 1)
warnings = []
settings = await get_booking_settings(session, service_center_id)
if not settings.accepts_online_booking:
warnings.append("online_booking_disabled")
if pending_links:
warnings.append("pending_vehicle_links")
if pending_appointments:
warnings.append("pending_appointments")
if not center.facade_photo_url:
warnings.append("missing_facade_photo")
return STODashboardRead(
service_center_id=service_center_id,
connected_vehicles=connected_vehicles,
pending_vehicle_links=pending_links,
active_appointments=pending_appointments + confirmed_appointments,
pending_appointments=pending_appointments,
confirmed_appointments=confirmed_appointments,
active_work_orders=active_work_orders,
completed_work_orders_month=int(completed_count or 0),
revenue_month=money_to_float(revenue_month),
average_check_month=average_check,
rating_avg=float(center.rating_avg) if center.rating_avg is not None else None,
reviews_count=center.reviews_count,
warnings=warnings,
)
@router.get("/sto/calendar", response_model=list[AppointmentRead])
async def get_sto_calendar(
service_center_id: int,
date_from: datetime | None = None,
date_to: datetime | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceAppointment]:
await ensure_service_employee(session, service_center_id, current_user)
start_at = _utc(date_from or datetime.now(UTC) - timedelta(days=1))
end_at = _utc(date_to or start_at + timedelta(days=30))
stmt = (
select(ServiceAppointment)
.where(
ServiceAppointment.service_center_id == service_center_id,
ServiceAppointment.requested_start_at >= start_at,
ServiceAppointment.requested_start_at <= end_at,
)
.order_by(ServiceAppointment.requested_start_at.asc())
)
return list((await session.execute(stmt)).scalars())
@router.get("/sto/appointments", response_model=list[AppointmentRead])
async def get_sto_appointments(
service_center_id: int,
status_filter: str | None = Query(default=None, alias="status"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[ServiceAppointment]:
await ensure_service_employee(session, service_center_id, current_user)
stmt = select(ServiceAppointment).where(ServiceAppointment.service_center_id == service_center_id)
if status_filter:
stmt = stmt.where(ServiceAppointment.status == status_filter)
stmt = stmt.order_by(ServiceAppointment.requested_start_at.desc())
return list((await session.execute(stmt)).scalars())
@router.post("/sto/appointments/{appointment_id}/confirm", response_model=AppointmentRead)
async def confirm_appointment(
appointment_id: int,
payload: AppointmentDecision,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceAppointment:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
if appointment.status not in {"requested", "proposed_new_time"}:
raise HTTPException(status_code=409, detail="Appointment cannot be confirmed")
await ensure_slot_available(
session,
service_center_id=appointment.service_center_id,
start_at=appointment.requested_start_at,
duration_minutes=appointment.estimated_duration_minutes,
exclude_appointment_id=appointment.id,
)
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
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="appointment.confirmed",
title="СТО подтвердило запись",
body=f"{appointment.service_name}: {appointment.confirmed_start_at:%Y-%m-%d %H:%M}",
)
await log_audit(session, actor=current_user, action="appointment.confirm", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/sto/appointments/{appointment_id}/reject", response_model=AppointmentRead)
async def reject_appointment(
appointment_id: int,
payload: AppointmentDecision,
session: AsyncSession = Depends(get_session),
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_owner", "cancelled_by_customer", "cancelled_by_sto"}:
raise HTTPException(status_code=409, detail="Appointment cannot be rejected")
appointment.status = "rejected_by_sto"
appointment.service_center_comment = payload.comment
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="appointment.rejected",
title="СТО отклонило запись",
body=payload.comment,
)
await log_audit(session, actor=current_user, action="appointment.reject", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.delete("/sto/appointments/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_appointment_by_sto(
appointment_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> None:
appointment = await _appointment_for_sto(session, appointment_id, current_user)
_ensure_appointment_deletable(appointment)
await _restore_recommendation_after_appointment_delete(session, appointment)
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=None,
notification_type="appointment.deleted_by_sto",
title="СТО удалило запись",
body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}",
idempotency_key=f"appointment:{appointment.id}:deleted_by_sto",
)
await log_audit(
session,
actor=current_user,
action="appointment.delete_by_sto",
target_type="service_appointment",
target_id=appointment_id,
)
await session.delete(appointment)
await session.commit()
@router.post("/sto/appointments/{appointment_id}/propose-time", response_model=AppointmentRead)
async def propose_appointment_time(
appointment_id: int,
payload: AppointmentProposeTime,
session: AsyncSession = Depends(get_session),
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_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)
proposed_end = await ensure_slot_available(
session,
service_center_id=appointment.service_center_id,
start_at=proposed_start,
duration_minutes=duration,
exclude_appointment_id=appointment.id,
)
appointment.estimated_duration_minutes = duration
appointment.proposed_start_at = proposed_start
appointment.proposed_end_at = proposed_end
appointment.status = "proposed_new_time"
appointment.service_center_comment = payload.comment
await create_service_notification(
session,
recipient_user_id=appointment.owner_id,
service_center_id=appointment.service_center_id,
appointment_id=appointment.id,
notification_type="appointment.proposed_new_time",
title="СТО предложило другое время",
body=f"{proposed_start:%Y-%m-%d %H:%M}",
)
await log_audit(session, actor=current_user, action="appointment.propose_time", target_type="service_appointment", target_id=appointment_id)
await session.commit()
await session.refresh(appointment)
return appointment
@router.post("/sto/appointments/{appointment_id}/create-work-order", response_model=ServiceVisitRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_from_appointment(
appointment_id: int,
payload: AppointmentCreateWorkOrder,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> 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 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)
if visit is None:
raise HTTPException(status_code=404, detail="Linked work order not found")
return visit
vehicle = await session.get(Car, appointment.vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
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 if payload.odometer is not None else vehicle.current_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)
return visit
@router.get("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
async def read_booking_settings(
service_center_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterBookingSettings:
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist"})
return await get_booking_settings(session, service_center_id)
@router.post("/sto/settings/booking", response_model=ServiceCenterBookingSettingsRead)
async def upsert_booking_settings(
payload: ServiceCenterBookingSettingsUpsert,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterBookingSettings:
await ensure_service_employee(session, payload.service_center_id, current_user, {"owner", "manager"})
settings = await get_booking_settings(session, payload.service_center_id)
for field, value in payload.model_dump(exclude={"service_center_id"}).items():
setattr(settings, field, value)
await log_audit(session, actor=current_user, action="sto_booking_settings.update", target_type="service_center", target_id=payload.service_center_id)
await session.commit()
await session.refresh(settings)
return settings
@router.post("/sto/settings/holidays", response_model=ServiceCenterHolidayRead, status_code=status.HTTP_201_CREATED)
async def create_holiday(
payload: ServiceCenterHolidayCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> ServiceCenterHoliday:
await ensure_service_employee(session, payload.service_center_id, current_user, {"owner", "manager"})
holiday = ServiceCenterHoliday(**payload.model_dump())
session.add(holiday)
await log_audit(session, actor=current_user, action="sto_holiday.create", target_type="service_center", target_id=payload.service_center_id)
await session.commit()
await session.refresh(holiday)
return holiday
@router.get("/vehicles/{vehicle_id}/maintenance-recommendations", response_model=list[MaintenanceRecommendationRead])
async def get_maintenance_recommendations(
vehicle_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> list[MaintenanceRecommendation]:
vehicle = await _owned_vehicle(session, vehicle_id, current_user)
await ensure_oil_recommendation(session, vehicle)
await session.commit()
stmt = (
select(MaintenanceRecommendation)
.where(MaintenanceRecommendation.vehicle_id == vehicle_id)
.order_by(MaintenanceRecommendation.status.asc(), MaintenanceRecommendation.due_date.asc().nullslast())
)
return list((await session.execute(stmt)).scalars())
@router.post("/vehicles/{vehicle_id}/maintenance-recommendations", response_model=MaintenanceRecommendationRead, status_code=status.HTTP_201_CREATED)
async def create_maintenance_recommendation(
vehicle_id: int,
payload: MaintenanceRecommendationCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> MaintenanceRecommendation:
await _owned_vehicle(session, vehicle_id, current_user)
recommendation = MaintenanceRecommendation(vehicle_id=vehicle_id, status="active", **payload.model_dump())
session.add(recommendation)
await create_service_notification(
session,
recipient_user_id=current_user.id,
notification_type="maintenance_recommendation.created",
title="Добавлена рекомендация ТО",
body=payload.title,
send_telegram=False,
)
await log_audit(session, actor=current_user, action="maintenance_recommendation.create", target_type="vehicle", target_id=vehicle_id)
await session.commit()
await session.refresh(recommendation)
return recommendation
@router.post("/maintenance-recommendations/{recommendation_id}/dismiss", response_model=MaintenanceRecommendationRead)
async def dismiss_maintenance_recommendation(
recommendation_id: int,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> MaintenanceRecommendation:
recommendation = await session.get(MaintenanceRecommendation, recommendation_id)
if recommendation is None:
raise HTTPException(status_code=404, detail="Recommendation not found")
await _owned_vehicle(session, recommendation.vehicle_id, current_user)
recommendation.status = "dismissed"
await log_audit(session, actor=current_user, action="maintenance_recommendation.dismiss", target_type="maintenance_recommendation", target_id=recommendation_id)
await session.commit()
await session.refresh(recommendation)
return recommendation
@router.post("/maintenance-recommendations/{recommendation_id}/book", response_model=MaintenanceRecommendationRead)
async def mark_recommendation_booked(
recommendation_id: int,
payload: MaintenanceRecommendationBook,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_telegram_user),
) -> MaintenanceRecommendation:
recommendation = await session.get(MaintenanceRecommendation, recommendation_id)
if recommendation is None:
raise HTTPException(status_code=404, detail="Recommendation not found")
await _owned_vehicle(session, recommendation.vehicle_id, current_user)
appointment = await _appointment_for_owner(session, payload.appointment_id, current_user)
if appointment.vehicle_id != recommendation.vehicle_id:
raise HTTPException(status_code=400, detail="Appointment does not match recommendation vehicle")
recommendation.status = "booked"
recommendation.source_appointment_id = appointment.id
appointment.source_recommendation_id = recommendation.id
await log_audit(session, actor=current_user, action="maintenance_recommendation.book", target_type="maintenance_recommendation", target_id=recommendation_id, metadata={"appointment_id": appointment.id})
await session.commit()
await session.refresh(recommendation)
return recommendation