796 lines
35 KiB
Python
796 lines
35 KiB
Python
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.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
|