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