Add STO booking and maintenance automation
This commit is contained in:
28
README.md
28
README.md
@@ -13,6 +13,9 @@ CarPass — цифровой паспорт автомобиля в Telegram. О
|
||||
- Мягкий прогресс заполнения профиля авто: VIN, госномер, пробег, масло, параметры обслуживания.
|
||||
- Бейджи качества истории без игровых очков и токсичных рейтингов.
|
||||
- Напоминания о ТО, страховке и важных событиях.
|
||||
- Автоматические рекомендации ТО по пробегу и времени, включая замену масла.
|
||||
- Онлайн-запись в проверенное СТО: выбор услуги, свободного окна, комментарий и согласование времени.
|
||||
- Мои записи: статусы requested, confirmed, proposed_new_time, cancelled, completed.
|
||||
- OCR чеков и разбор свободного текста: пользователь проверяет найденные данные перед сохранением.
|
||||
- История одометра: пробег обновляется из записей, а спорные значения требуют подтверждения.
|
||||
|
||||
@@ -22,10 +25,33 @@ CarPass — цифровой паспорт автомобиля в Telegram. О
|
||||
- Заявка на проверку и статус модерации.
|
||||
- Модерация заявок: approved, rejected, needs changes, suspended.
|
||||
- Публичная карточка СТО после подтверждения.
|
||||
- Каталог СТО показывает только подтвержденные сервисы, город, специализации, рейтинг и ближайшее свободное окно.
|
||||
- Кабинет СТО: подключенные авто, новые заявки, подтвержденные записи, активные заказ-наряды, выручка и предупреждения.
|
||||
- Настройка графика: рабочие дни, часы, обед, праздники, длительность слота, параллельные записи и онлайн-запись.
|
||||
- Календарь записей: заявки клиентов, подтверждение, отклонение, предложение нового времени.
|
||||
- Создание заказ-наряда из подтвержденной записи на базе существующего `ServiceVisit`.
|
||||
- Отзывы, рейтинг и ответы сервиса.
|
||||
- Запрос доступа к конкретному автомобилю только с подтверждением владельца.
|
||||
- Добавление визитов, работ и рекомендаций с аудитом действий.
|
||||
|
||||
## Запись и обслуживание
|
||||
|
||||
Если автомобиль уже привязан к СТО, владелец может открыть карточку авто и записаться сразу в календарь этого сервиса. Если привязки нет, пользователь выбирает СТО из каталога, смотрит свободные окна и создает заявку.
|
||||
|
||||
СТО получает уведомление о новой заявке, подтверждает время, отклоняет запись или предлагает другое окно. Когда запись подтверждена, она появляется в календаре СТО. После визита сервис может создать заказ-наряд из записи, провести работы, отправить результат владельцу и обновить историю автомобиля через существующий сценарий подтверждения визита.
|
||||
|
||||
## Рекомендации ТО
|
||||
|
||||
CarPass создает рекомендации обслуживания из данных автомобиля и истории работ. Сейчас базово поддержана плановая замена масла по интервалу пробега и времени, ручные рекомендации владельца и связь рекомендации с записью в СТО. Просроченные и близкие рекомендации получают приоритет, но формулируются мягко: пользователь видит действие “записаться”, а не тревожную ошибку.
|
||||
|
||||
## Уведомления
|
||||
|
||||
В системе создаются внутренние уведомления и, если настроен Telegram Bot Token, отправляются сообщения в Telegram:
|
||||
|
||||
- СТО получает новую заявку на запись, отмену клиента и решение по предложенному времени.
|
||||
- Владелец получает подтверждение, отклонение или предложение нового времени.
|
||||
- Рекомендации ТО фиксируются в истории уведомлений.
|
||||
|
||||
## Безопасность данных
|
||||
|
||||
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||
@@ -42,6 +68,8 @@ Mini App открывается через кнопку внутри Telegram-б
|
||||
- `/fuel`, `/service`, `/insurance`, `/tax`, `/fine` — быстрые записи текстом.
|
||||
- `/analytics` — стоимость владения и расход.
|
||||
- `/sto`, `/register_sto` — каталог и регистрация СТО.
|
||||
- `/appointments` — записи владельца в СТО.
|
||||
- `/sto_bookings` — краткий отчет по заявкам и календарю СТО.
|
||||
|
||||
## Почему это полезно
|
||||
|
||||
|
||||
202
alembic/versions/202605150002_sto_booking_automation.py
Normal file
202
alembic/versions/202605150002_sto_booking_automation.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""sto booking automation
|
||||
|
||||
Revision ID: 202605150002
|
||||
Revises: 202605150001
|
||||
Create Date: 2026-05-15 08:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "202605150002"
|
||||
down_revision: str | None = "202605150001"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"service_center_booking_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||
sa.Column("working_days", sa.JSON(), server_default=sa.text("'[0,1,2,3,4]'"), nullable=False),
|
||||
sa.Column("open_time", sa.Time(), server_default="09:00:00", nullable=False),
|
||||
sa.Column("close_time", sa.Time(), server_default="18:00:00", nullable=False),
|
||||
sa.Column("lunch_break_start", sa.Time(), nullable=True),
|
||||
sa.Column("lunch_break_end", sa.Time(), nullable=True),
|
||||
sa.Column("timezone", sa.String(length=64), server_default="Asia/Seoul", nullable=False),
|
||||
sa.Column("slot_duration_minutes", sa.Integer(), server_default="30", nullable=False),
|
||||
sa.Column("booking_buffer_minutes", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("max_parallel_bookings", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column("accepts_online_booking", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("service_center_id"),
|
||||
)
|
||||
op.create_index("ix_service_center_booking_settings_accepts_online_booking", "service_center_booking_settings", ["accepts_online_booking"])
|
||||
op.create_index("ix_service_center_booking_settings_service_center_id", "service_center_booking_settings", ["service_center_id"])
|
||||
|
||||
op.create_table(
|
||||
"service_center_holidays",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||
sa.Column("holiday_date", sa.Date(), nullable=False),
|
||||
sa.Column("reason", sa.String(length=240), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("service_center_id", "holiday_date", name="uq_service_center_holiday"),
|
||||
)
|
||||
op.create_index("ix_service_center_holidays_holiday_date", "service_center_holidays", ["holiday_date"])
|
||||
op.create_index("ix_service_center_holidays_service_center_id", "service_center_holidays", ["service_center_id"])
|
||||
|
||||
op.create_table(
|
||||
"maintenance_recommendations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehicle_id", sa.Integer(), nullable=False),
|
||||
sa.Column("recommendation_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("title", sa.String(length=180), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("due_odometer_km", sa.Integer(), nullable=True),
|
||||
sa.Column("due_date", sa.Date(), nullable=True),
|
||||
sa.Column("priority", sa.String(length=24), server_default="medium", nullable=False),
|
||||
sa.Column("status", sa.String(length=24), server_default="active", nullable=False),
|
||||
sa.Column("source", sa.String(length=40), server_default="system_analysis", nullable=False),
|
||||
sa.Column("source_service_center_id", sa.Integer(), nullable=True),
|
||||
sa.Column("source_appointment_id", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["source_service_center_id"], ["service_centers.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_maintenance_recommendations_created_at", "maintenance_recommendations", ["created_at"])
|
||||
op.create_index("ix_maintenance_recommendations_due_date", "maintenance_recommendations", ["due_date"])
|
||||
op.create_index("ix_maintenance_recommendations_due_odometer_km", "maintenance_recommendations", ["due_odometer_km"])
|
||||
op.create_index("ix_maintenance_recommendations_priority", "maintenance_recommendations", ["priority"])
|
||||
op.create_index("ix_maintenance_recommendations_recommendation_type", "maintenance_recommendations", ["recommendation_type"])
|
||||
op.create_index("ix_maintenance_recommendations_source", "maintenance_recommendations", ["source"])
|
||||
op.create_index("ix_maintenance_recommendations_source_appointment_id", "maintenance_recommendations", ["source_appointment_id"])
|
||||
op.create_index("ix_maintenance_recommendations_source_service_center_id", "maintenance_recommendations", ["source_service_center_id"])
|
||||
op.create_index("ix_maintenance_recommendations_status", "maintenance_recommendations", ["status"])
|
||||
op.create_index("ix_maintenance_recommendations_vehicle_id", "maintenance_recommendations", ["vehicle_id"])
|
||||
|
||||
op.create_table(
|
||||
"service_appointments",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("service_center_id", sa.Integer(), nullable=False),
|
||||
sa.Column("vehicle_id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_by", sa.Integer(), nullable=False),
|
||||
sa.Column("service_type", sa.String(length=64), nullable=False),
|
||||
sa.Column("service_name", sa.String(length=180), nullable=False),
|
||||
sa.Column("requested_start_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("requested_end_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("confirmed_start_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("confirmed_end_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("proposed_start_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("proposed_end_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("estimated_duration_minutes", sa.Integer(), server_default="60", nullable=False),
|
||||
sa.Column("status", sa.String(length=40), server_default="requested", nullable=False),
|
||||
sa.Column("customer_comment", sa.Text(), nullable=True),
|
||||
sa.Column("service_center_comment", sa.Text(), nullable=True),
|
||||
sa.Column("source_recommendation_id", sa.Integer(), nullable=True),
|
||||
sa.Column("linked_work_order_id", sa.Integer(), nullable=True),
|
||||
sa.Column("cancellation_reason", sa.Text(), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["linked_work_order_id"], ["service_visits.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["source_recommendation_id"], ["maintenance_recommendations.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["vehicle_id"], ["cars.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_service_appointments_confirmed_start_at", "service_appointments", ["confirmed_start_at"])
|
||||
op.create_index("ix_service_appointments_created_at", "service_appointments", ["created_at"])
|
||||
op.create_index("ix_service_appointments_created_by", "service_appointments", ["created_by"])
|
||||
op.create_index("ix_service_appointments_linked_work_order_id", "service_appointments", ["linked_work_order_id"])
|
||||
op.create_index("ix_service_appointments_owner_id", "service_appointments", ["owner_id"])
|
||||
op.create_index("ix_service_appointments_proposed_start_at", "service_appointments", ["proposed_start_at"])
|
||||
op.create_index("ix_service_appointments_requested_end_at", "service_appointments", ["requested_end_at"])
|
||||
op.create_index("ix_service_appointments_requested_start_at", "service_appointments", ["requested_start_at"])
|
||||
op.create_index("ix_service_appointments_service_center_id", "service_appointments", ["service_center_id"])
|
||||
op.create_index("ix_service_appointments_service_type", "service_appointments", ["service_type"])
|
||||
op.create_index("ix_service_appointments_source_recommendation_id", "service_appointments", ["source_recommendation_id"])
|
||||
op.create_index("ix_service_appointments_status", "service_appointments", ["status"])
|
||||
op.create_index("ix_service_appointments_vehicle_id", "service_appointments", ["vehicle_id"])
|
||||
|
||||
op.create_table(
|
||||
"service_notifications",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("recipient_user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("service_center_id", sa.Integer(), nullable=True),
|
||||
sa.Column("appointment_id", sa.Integer(), nullable=True),
|
||||
sa.Column("notification_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("title", sa.String(length=180), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), server_default="unread", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["appointment_id"], ["service_appointments.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["recipient_user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_service_notifications_appointment_id", "service_notifications", ["appointment_id"])
|
||||
op.create_index("ix_service_notifications_created_at", "service_notifications", ["created_at"])
|
||||
op.create_index("ix_service_notifications_notification_type", "service_notifications", ["notification_type"])
|
||||
op.create_index("ix_service_notifications_recipient_user_id", "service_notifications", ["recipient_user_id"])
|
||||
op.create_index("ix_service_notifications_service_center_id", "service_notifications", ["service_center_id"])
|
||||
op.create_index("ix_service_notifications_status", "service_notifications", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_service_notifications_status", table_name="service_notifications")
|
||||
op.drop_index("ix_service_notifications_service_center_id", table_name="service_notifications")
|
||||
op.drop_index("ix_service_notifications_recipient_user_id", table_name="service_notifications")
|
||||
op.drop_index("ix_service_notifications_notification_type", table_name="service_notifications")
|
||||
op.drop_index("ix_service_notifications_created_at", table_name="service_notifications")
|
||||
op.drop_index("ix_service_notifications_appointment_id", table_name="service_notifications")
|
||||
op.drop_table("service_notifications")
|
||||
|
||||
op.drop_index("ix_service_appointments_vehicle_id", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_status", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_source_recommendation_id", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_service_type", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_service_center_id", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_requested_start_at", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_requested_end_at", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_proposed_start_at", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_owner_id", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_linked_work_order_id", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_created_by", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_created_at", table_name="service_appointments")
|
||||
op.drop_index("ix_service_appointments_confirmed_start_at", table_name="service_appointments")
|
||||
op.drop_table("service_appointments")
|
||||
|
||||
op.drop_index("ix_maintenance_recommendations_vehicle_id", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_status", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_source_service_center_id", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_source_appointment_id", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_source", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_recommendation_type", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_priority", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_due_odometer_km", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_due_date", table_name="maintenance_recommendations")
|
||||
op.drop_index("ix_maintenance_recommendations_created_at", table_name="maintenance_recommendations")
|
||||
op.drop_table("maintenance_recommendations")
|
||||
|
||||
op.drop_index("ix_service_center_holidays_service_center_id", table_name="service_center_holidays")
|
||||
op.drop_index("ix_service_center_holidays_holiday_date", table_name="service_center_holidays")
|
||||
op.drop_table("service_center_holidays")
|
||||
|
||||
op.drop_index("ix_service_center_booking_settings_service_center_id", table_name="service_center_booking_settings")
|
||||
op.drop_index("ix_service_center_booking_settings_accepts_online_booking", table_name="service_center_booking_settings")
|
||||
op.drop_table("service_center_booking_settings")
|
||||
696
app/api/sto_booking.py
Normal file
696
app/api/sto_booking.py
Normal file
@@ -0,0 +1,696 @@
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, 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.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,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceAppointment:
|
||||
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_customer", "cancelled_by_sto"}:
|
||||
raise HTTPException(status_code=409, detail="Appointment cannot be cancelled")
|
||||
appointment.status = "cancelled_by_customer"
|
||||
appointment.cancelled_at = datetime.now(UTC)
|
||||
appointment.cancellation_reason = payload.reason
|
||||
await notify_service_staff(
|
||||
session,
|
||||
service_center_id=appointment.service_center_id,
|
||||
notification_type="appointment.cancelled_by_customer",
|
||||
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.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"
|
||||
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 == "confirmed",
|
||||
))).scalar_one() or 0
|
||||
)
|
||||
active_work_orders = int(
|
||||
(await session.execute(select(func.count(ServiceVisit.id)).where(
|
||||
ServiceVisit.service_center_id == service_center_id,
|
||||
ServiceVisit.status.in_(["draft", "pending_owner_confirmation"]),
|
||||
))).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"
|
||||
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_customer", "cancelled_by_sto"}:
|
||||
raise HTTPException(status_code=409, detail="Appointment cannot be rejected")
|
||||
appointment.status = "rejected"
|
||||
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.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_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 != "confirmed":
|
||||
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
|
||||
visit = ServiceVisit(
|
||||
service_center_id=appointment.service_center_id,
|
||||
vehicle_id=appointment.vehicle_id,
|
||||
created_by_employee_id=employee.id,
|
||||
visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(),
|
||||
odometer=payload.odometer,
|
||||
status="draft",
|
||||
notes=payload.notes or appointment.customer_comment,
|
||||
)
|
||||
session.add(visit)
|
||||
await session.flush()
|
||||
appointment.linked_work_order_id = visit.id
|
||||
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
|
||||
@@ -14,6 +14,7 @@ from app.api import (
|
||||
parser,
|
||||
service_centers,
|
||||
service_visits,
|
||||
sto_booking,
|
||||
users,
|
||||
)
|
||||
from app.core.config import settings
|
||||
@@ -40,6 +41,7 @@ app.include_router(gamification.router, prefix="/api")
|
||||
app.include_router(ocr.router, prefix="/api")
|
||||
app.include_router(parser.router, prefix="/api")
|
||||
app.include_router(service_centers.router, prefix="/api")
|
||||
app.include_router(sto_booking.router, prefix="/api")
|
||||
app.include_router(service_visits.router, prefix="/api")
|
||||
app.include_router(change_requests.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import (
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
Time,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
@@ -163,6 +164,14 @@ class ServiceCenter(Base):
|
||||
employees = relationship("ServiceEmployee", back_populates="service_center", cascade="all, delete-orphan")
|
||||
visits = relationship("ServiceVisit", back_populates="service_center")
|
||||
reviews = relationship("ServiceCenterReview", back_populates="service_center", cascade="all, delete-orphan")
|
||||
booking_settings = relationship(
|
||||
"ServiceCenterBookingSettings",
|
||||
back_populates="service_center",
|
||||
cascade="all, delete-orphan",
|
||||
uselist=False,
|
||||
)
|
||||
holidays = relationship("ServiceCenterHoliday", back_populates="service_center", cascade="all, delete-orphan")
|
||||
appointments = relationship("ServiceAppointment", back_populates="service_center", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CarServiceLink(Base):
|
||||
@@ -260,6 +269,44 @@ class ServiceEmployee(Base):
|
||||
service_center = relationship("ServiceCenter", back_populates="employees")
|
||||
|
||||
|
||||
class ServiceCenterBookingSettings(Base):
|
||||
__tablename__ = "service_center_booking_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("service_centers.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
working_days: Mapped[list] = mapped_column(JSON, default=lambda: [0, 1, 2, 3, 4], server_default="[0,1,2,3,4]")
|
||||
open_time: Mapped[time] = mapped_column(Time, default=time(9, 0), server_default="09:00:00")
|
||||
close_time: Mapped[time] = mapped_column(Time, default=time(18, 0), server_default="18:00:00")
|
||||
lunch_break_start: Mapped[time | None] = mapped_column(Time)
|
||||
lunch_break_end: Mapped[time | None] = mapped_column(Time)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Seoul", server_default="Asia/Seoul")
|
||||
slot_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, server_default="30")
|
||||
booking_buffer_minutes: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
max_parallel_bookings: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
accepts_online_booking: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="booking_settings")
|
||||
|
||||
|
||||
class ServiceCenterHoliday(Base):
|
||||
__tablename__ = "service_center_holidays"
|
||||
__table_args__ = (UniqueConstraint("service_center_id", "holiday_date", name="uq_service_center_holiday"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
holiday_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
reason: Mapped[str | None] = mapped_column(String(240))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="holidays")
|
||||
|
||||
|
||||
class ServiceVisit(Base):
|
||||
__tablename__ = "service_visits"
|
||||
|
||||
@@ -283,6 +330,75 @@ class ServiceVisit(Base):
|
||||
work_items = relationship("ServiceWorkItem", back_populates="visit", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class MaintenanceRecommendation(Base):
|
||||
__tablename__ = "maintenance_recommendations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
recommendation_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
due_odometer_km: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, index=True)
|
||||
priority: Mapped[str] = mapped_column(String(24), default="medium", server_default="medium", index=True)
|
||||
status: Mapped[str] = mapped_column(String(24), default="active", server_default="active", index=True)
|
||||
source: Mapped[str] = mapped_column(String(40), default="system_analysis", server_default="system_analysis", index=True)
|
||||
source_service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
|
||||
source_appointment_id: Mapped[int | None] = mapped_column(Integer, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ServiceAppointment(Base):
|
||||
__tablename__ = "service_appointments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
vehicle_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
service_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||
service_name: Mapped[str] = mapped_column(String(180))
|
||||
requested_start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
requested_end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
confirmed_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
confirmed_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
proposed_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
proposed_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
estimated_duration_minutes: Mapped[int] = mapped_column(Integer, default=60, server_default="60")
|
||||
status: Mapped[str] = mapped_column(String(40), default="requested", server_default="requested", index=True)
|
||||
customer_comment: Mapped[str | None] = mapped_column(Text)
|
||||
service_center_comment: Mapped[str | None] = mapped_column(Text)
|
||||
source_recommendation_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("maintenance_recommendations.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
linked_work_order_id: Mapped[int | None] = mapped_column(ForeignKey("service_visits.id", ondelete="SET NULL"), index=True)
|
||||
cancellation_reason: Mapped[str | None] = mapped_column(Text)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="appointments")
|
||||
|
||||
|
||||
class ServiceNotification(Base):
|
||||
__tablename__ = "service_notifications"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
recipient_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="SET NULL"), index=True)
|
||||
appointment_id: Mapped[int | None] = mapped_column(ForeignKey("service_appointments.id", ondelete="SET NULL"), index=True)
|
||||
notification_type: Mapped[str] = mapped_column(String(80), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
body: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(24), default="unread", server_default="unread", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class ServiceWorkItem(Base):
|
||||
__tablename__ = "service_work_items"
|
||||
|
||||
|
||||
187
app/schemas/sto_booking.py
Normal file
187
app/schemas/sto_booking.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
APPOINTMENT_STATUSES = {
|
||||
"draft",
|
||||
"requested",
|
||||
"confirmed",
|
||||
"proposed_new_time",
|
||||
"rejected",
|
||||
"cancelled_by_customer",
|
||||
"cancelled_by_sto",
|
||||
"completed",
|
||||
"no_show",
|
||||
}
|
||||
|
||||
|
||||
class AvailableSlotRead(BaseModel):
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
|
||||
|
||||
class ServiceCatalogItem(BaseModel):
|
||||
id: int
|
||||
display_name: str | None = None
|
||||
name: str
|
||||
city: str | None = None
|
||||
address: str | None = None
|
||||
specializations: list[str] | None = None
|
||||
working_hours: str | None = None
|
||||
rating_avg: float | None = None
|
||||
reviews_count: int = 0
|
||||
nearest_slot_at: datetime | None = None
|
||||
accepts_online_booking: bool = True
|
||||
|
||||
|
||||
class ServiceCenterBookingSettingsUpsert(BaseModel):
|
||||
service_center_id: int
|
||||
working_days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4])
|
||||
open_time: time = time(9, 0)
|
||||
close_time: time = time(18, 0)
|
||||
lunch_break_start: time | None = None
|
||||
lunch_break_end: time | None = None
|
||||
timezone: str = "Asia/Seoul"
|
||||
slot_duration_minutes: int = Field(default=30, ge=10, le=240)
|
||||
booking_buffer_minutes: int = Field(default=0, ge=0, le=240)
|
||||
max_parallel_bookings: int = Field(default=1, ge=1, le=20)
|
||||
accepts_online_booking: bool = True
|
||||
|
||||
@field_validator("working_days")
|
||||
@classmethod
|
||||
def validate_working_days(cls, value: list[int]) -> list[int]:
|
||||
days = sorted(set(value))
|
||||
if any(day < 0 or day > 6 for day in days):
|
||||
raise ValueError("working_days must contain ISO weekdays 0..6")
|
||||
return days
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_times(self) -> "ServiceCenterBookingSettingsUpsert":
|
||||
if self.open_time >= self.close_time:
|
||||
raise ValueError("open_time must be before close_time")
|
||||
if bool(self.lunch_break_start) != bool(self.lunch_break_end):
|
||||
raise ValueError("both lunch break boundaries are required")
|
||||
if self.lunch_break_start and self.lunch_break_end and self.lunch_break_start >= self.lunch_break_end:
|
||||
raise ValueError("lunch_break_start must be before lunch_break_end")
|
||||
return self
|
||||
|
||||
|
||||
class ServiceCenterBookingSettingsRead(ServiceCenterBookingSettingsUpsert):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceCenterHolidayCreate(BaseModel):
|
||||
service_center_id: int
|
||||
holiday_date: date
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class ServiceCenterHolidayRead(ServiceCenterHolidayCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AppointmentCreate(BaseModel):
|
||||
service_center_id: int
|
||||
vehicle_id: int
|
||||
service_type: str = Field(default="maintenance", max_length=64)
|
||||
service_name: str = Field(default="Обслуживание", max_length=180)
|
||||
requested_start_at: datetime
|
||||
estimated_duration_minutes: int = Field(default=60, ge=10, le=1440)
|
||||
customer_comment: str | None = Field(default=None, max_length=4000)
|
||||
source_recommendation_id: int | None = None
|
||||
|
||||
|
||||
class AppointmentRead(BaseModel):
|
||||
id: int
|
||||
service_center_id: int
|
||||
vehicle_id: int
|
||||
owner_id: int
|
||||
created_by: int
|
||||
service_type: str
|
||||
service_name: str
|
||||
requested_start_at: datetime
|
||||
requested_end_at: datetime
|
||||
confirmed_start_at: datetime | None = None
|
||||
confirmed_end_at: datetime | None = None
|
||||
proposed_start_at: datetime | None = None
|
||||
proposed_end_at: datetime | None = None
|
||||
estimated_duration_minutes: int
|
||||
status: str
|
||||
customer_comment: str | None = None
|
||||
service_center_comment: str | None = None
|
||||
source_recommendation_id: int | None = None
|
||||
linked_work_order_id: int | None = None
|
||||
cancellation_reason: str | None = None
|
||||
cancelled_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AppointmentDecision(BaseModel):
|
||||
comment: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class AppointmentProposeTime(BaseModel):
|
||||
proposed_start_at: datetime
|
||||
estimated_duration_minutes: int | None = Field(default=None, ge=10, le=1440)
|
||||
comment: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class AppointmentCancel(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class AppointmentCreateWorkOrder(BaseModel):
|
||||
odometer: int | None = None
|
||||
notes: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class MaintenanceRecommendationCreate(BaseModel):
|
||||
recommendation_type: str = Field(max_length=64)
|
||||
title: str = Field(max_length=180)
|
||||
description: str | None = None
|
||||
due_odometer_km: int | None = None
|
||||
due_date: date | None = None
|
||||
priority: str = "medium"
|
||||
source: str = "user_rule"
|
||||
|
||||
|
||||
class MaintenanceRecommendationRead(MaintenanceRecommendationCreate):
|
||||
id: int
|
||||
vehicle_id: int
|
||||
status: str
|
||||
source_service_center_id: int | None = None
|
||||
source_appointment_id: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MaintenanceRecommendationBook(BaseModel):
|
||||
appointment_id: int
|
||||
|
||||
|
||||
class STODashboardRead(BaseModel):
|
||||
service_center_id: int
|
||||
connected_vehicles: int
|
||||
pending_vehicle_links: int
|
||||
active_appointments: int
|
||||
pending_appointments: int
|
||||
confirmed_appointments: int
|
||||
active_work_orders: int
|
||||
completed_work_orders_month: int
|
||||
revenue_month: float
|
||||
average_check_month: float
|
||||
rating_avg: float | None = None
|
||||
reviews_count: int
|
||||
warnings: list[str]
|
||||
338
app/services/sto_booking.py
Normal file
338
app/services/sto_booking.py
Normal file
@@ -0,0 +1,338 @@
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.car import (
|
||||
Car,
|
||||
MaintenanceRecommendation,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceCenterBookingSettings,
|
||||
ServiceCenterHoliday,
|
||||
ServiceEmployee,
|
||||
ServiceNotification,
|
||||
ServiceVisit,
|
||||
ServiceWorkItem,
|
||||
)
|
||||
from app.models.expense import ServiceEntry
|
||||
from app.models.user import User
|
||||
from app.services.notifications import notify_user
|
||||
|
||||
ACTIVE_APPOINTMENT_STATUSES = {"requested", "confirmed", "proposed_new_time"}
|
||||
DEFAULT_SERVICE_DURATIONS = {
|
||||
"oil_change": 60,
|
||||
"diagnostics": 60,
|
||||
"repair": 120,
|
||||
"tire_service": 60,
|
||||
"brakes": 90,
|
||||
"fluids": 60,
|
||||
"maintenance": 90,
|
||||
"other": 60,
|
||||
}
|
||||
|
||||
|
||||
def _as_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def estimate_duration(service_type: str, requested: int | None = None) -> int:
|
||||
return requested or DEFAULT_SERVICE_DURATIONS.get(service_type, DEFAULT_SERVICE_DURATIONS["other"])
|
||||
|
||||
|
||||
async def get_booking_settings(
|
||||
session: AsyncSession, service_center_id: int
|
||||
) -> ServiceCenterBookingSettings:
|
||||
result = await session.execute(
|
||||
select(ServiceCenterBookingSettings).where(
|
||||
ServiceCenterBookingSettings.service_center_id == service_center_id
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
if settings is not None:
|
||||
return settings
|
||||
settings = ServiceCenterBookingSettings(service_center_id=service_center_id)
|
||||
session.add(settings)
|
||||
await session.flush()
|
||||
return settings
|
||||
|
||||
|
||||
def _slot_overlaps_lunch(start_time: time, end_time: time, settings: ServiceCenterBookingSettings) -> bool:
|
||||
if not settings.lunch_break_start or not settings.lunch_break_end:
|
||||
return False
|
||||
return start_time < settings.lunch_break_end and end_time > settings.lunch_break_start
|
||||
|
||||
|
||||
async def count_overlapping_appointments(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
service_center_id: int,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
exclude_appointment_id: int | None = None,
|
||||
) -> int:
|
||||
start_at = _as_aware_utc(start_at)
|
||||
end_at = _as_aware_utc(end_at)
|
||||
stmt = select(func.count(ServiceAppointment.id)).where(
|
||||
ServiceAppointment.service_center_id == service_center_id,
|
||||
ServiceAppointment.status.in_(ACTIVE_APPOINTMENT_STATUSES),
|
||||
ServiceAppointment.requested_start_at < end_at,
|
||||
ServiceAppointment.requested_end_at > start_at,
|
||||
)
|
||||
if exclude_appointment_id:
|
||||
stmt = stmt.where(ServiceAppointment.id != exclude_appointment_id)
|
||||
return int((await session.execute(stmt)).scalar_one() or 0)
|
||||
|
||||
|
||||
async def ensure_slot_available(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
service_center_id: int,
|
||||
start_at: datetime,
|
||||
duration_minutes: int,
|
||||
exclude_appointment_id: int | None = None,
|
||||
) -> datetime:
|
||||
start_at = _as_aware_utc(start_at)
|
||||
now = datetime.now(UTC)
|
||||
if start_at <= now:
|
||||
raise HTTPException(status_code=409, detail="Cannot book past time")
|
||||
end_at = start_at + timedelta(minutes=duration_minutes)
|
||||
settings = await get_booking_settings(session, service_center_id)
|
||||
if not settings.accepts_online_booking:
|
||||
raise HTTPException(status_code=409, detail="Online booking is disabled")
|
||||
holiday = await session.execute(
|
||||
select(ServiceCenterHoliday).where(
|
||||
ServiceCenterHoliday.service_center_id == service_center_id,
|
||||
ServiceCenterHoliday.holiday_date == start_at.date(),
|
||||
)
|
||||
)
|
||||
if holiday.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Service center is closed on this date")
|
||||
working_days = settings.working_days or [0, 1, 2, 3, 4]
|
||||
if start_at.weekday() not in working_days:
|
||||
raise HTTPException(status_code=409, detail="Service center is closed on this weekday")
|
||||
if start_at.time() < settings.open_time or end_at.time() > settings.close_time:
|
||||
raise HTTPException(status_code=409, detail="Slot is outside working hours")
|
||||
if _slot_overlaps_lunch(start_at.time(), end_at.time(), settings):
|
||||
raise HTTPException(status_code=409, detail="Slot overlaps lunch break")
|
||||
overlapping = await count_overlapping_appointments(
|
||||
session,
|
||||
service_center_id=service_center_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
exclude_appointment_id=exclude_appointment_id,
|
||||
)
|
||||
if overlapping >= settings.max_parallel_bookings:
|
||||
raise HTTPException(status_code=409, detail="Slot is already full")
|
||||
return end_at
|
||||
|
||||
|
||||
async def calculate_available_slots(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
service_center_id: int,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
duration_minutes: int,
|
||||
limit: int = 80,
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
settings = await get_booking_settings(session, service_center_id)
|
||||
holidays = {
|
||||
item.holiday_date
|
||||
for item in (
|
||||
await session.execute(
|
||||
select(ServiceCenterHoliday).where(
|
||||
ServiceCenterHoliday.service_center_id == service_center_id,
|
||||
ServiceCenterHoliday.holiday_date >= date_from,
|
||||
ServiceCenterHoliday.holiday_date <= date_to,
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
slots: list[tuple[datetime, datetime]] = []
|
||||
now = datetime.now(UTC)
|
||||
current = date_from
|
||||
step = timedelta(minutes=settings.slot_duration_minutes + settings.booking_buffer_minutes)
|
||||
working_days = settings.working_days or [0, 1, 2, 3, 4]
|
||||
while current <= date_to and len(slots) < limit:
|
||||
if current.weekday() not in working_days or current in holidays:
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
cursor = datetime.combine(current, settings.open_time, tzinfo=UTC)
|
||||
day_close = datetime.combine(current, settings.close_time, tzinfo=UTC)
|
||||
while cursor + timedelta(minutes=duration_minutes) <= day_close and len(slots) < limit:
|
||||
end_at = cursor + timedelta(minutes=duration_minutes)
|
||||
if cursor > now and not _slot_overlaps_lunch(cursor.time(), end_at.time(), settings):
|
||||
overlapping = await count_overlapping_appointments(
|
||||
session,
|
||||
service_center_id=service_center_id,
|
||||
start_at=cursor,
|
||||
end_at=end_at,
|
||||
)
|
||||
if overlapping < settings.max_parallel_bookings:
|
||||
slots.append((cursor, end_at))
|
||||
cursor += step
|
||||
current += timedelta(days=1)
|
||||
return slots
|
||||
|
||||
|
||||
async def create_service_notification(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
recipient_user_id: int,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
service_center_id: int | None = None,
|
||||
appointment_id: int | None = None,
|
||||
send_telegram: bool = True,
|
||||
) -> ServiceNotification:
|
||||
notification = ServiceNotification(
|
||||
recipient_user_id=recipient_user_id,
|
||||
service_center_id=service_center_id,
|
||||
appointment_id=appointment_id,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
body=body,
|
||||
)
|
||||
session.add(notification)
|
||||
if send_telegram:
|
||||
user = await session.get(User, recipient_user_id)
|
||||
if user is not None:
|
||||
await notify_user(user, f"{title}\n{body}" if body else title)
|
||||
return notification
|
||||
|
||||
|
||||
async def notify_service_staff(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
service_center_id: int,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
appointment_id: int | None = None,
|
||||
) -> None:
|
||||
center = await session.get(ServiceCenter, service_center_id)
|
||||
recipient_ids: set[int] = set()
|
||||
if center and center.owner_user_id:
|
||||
recipient_ids.add(center.owner_user_id)
|
||||
employees = await session.execute(
|
||||
select(ServiceEmployee).where(
|
||||
ServiceEmployee.service_center_id == service_center_id,
|
||||
ServiceEmployee.status == "active",
|
||||
)
|
||||
)
|
||||
recipient_ids.update(employee.user_id for employee in employees.scalars())
|
||||
for user_id in recipient_ids:
|
||||
await create_service_notification(
|
||||
session,
|
||||
recipient_user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
body=body,
|
||||
service_center_id=service_center_id,
|
||||
appointment_id=appointment_id,
|
||||
)
|
||||
|
||||
|
||||
def _recommendation_priority(car: Car, due_odometer: int | None, due_date: date | None) -> str:
|
||||
today = date.today()
|
||||
current_odometer = car.current_odometer or 0
|
||||
if due_odometer is not None and current_odometer >= due_odometer:
|
||||
return "overdue"
|
||||
if due_date is not None and today >= due_date:
|
||||
return "overdue"
|
||||
if due_odometer is not None and due_odometer - current_odometer <= 1000:
|
||||
return "high"
|
||||
if due_date is not None and (due_date - today).days <= 14:
|
||||
return "high"
|
||||
return "medium"
|
||||
|
||||
|
||||
async def ensure_oil_recommendation(session: AsyncSession, car: Car) -> MaintenanceRecommendation | None:
|
||||
interval_km = car.oil_change_interval_km or 10_000
|
||||
interval_months = car.oil_change_interval_months or 12
|
||||
latest_entry = (
|
||||
await session.execute(
|
||||
select(ServiceEntry)
|
||||
.where(
|
||||
ServiceEntry.car_id == car.id,
|
||||
or_(
|
||||
ServiceEntry.title.ilike("%масл%"),
|
||||
ServiceEntry.title.ilike("%oil%"),
|
||||
ServiceEntry.category.ilike("%масл%"),
|
||||
ServiceEntry.category.ilike("%oil%"),
|
||||
),
|
||||
)
|
||||
.order_by(ServiceEntry.entry_date.desc(), ServiceEntry.id.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
latest_work_item = (
|
||||
await session.execute(
|
||||
select(ServiceWorkItem, ServiceVisit)
|
||||
.join(ServiceVisit, ServiceVisit.id == ServiceWorkItem.service_visit_id)
|
||||
.where(
|
||||
ServiceVisit.vehicle_id == car.id,
|
||||
or_(
|
||||
ServiceWorkItem.work_type.in_(["oil_change", "fluids"]),
|
||||
ServiceWorkItem.title.ilike("%масл%"),
|
||||
ServiceWorkItem.title.ilike("%oil%"),
|
||||
),
|
||||
)
|
||||
.order_by(ServiceVisit.visit_date.desc(), ServiceWorkItem.id.desc())
|
||||
)
|
||||
).first()
|
||||
base_odometer = car.current_odometer or 0
|
||||
base_date = date.today()
|
||||
if latest_entry:
|
||||
base_odometer = latest_entry.odometer or base_odometer
|
||||
base_date = latest_entry.entry_date or base_date
|
||||
if latest_work_item:
|
||||
work_item, visit = latest_work_item
|
||||
base_odometer = visit.odometer or base_odometer
|
||||
base_date = visit.visit_date or base_date
|
||||
if work_item.next_due_odometer:
|
||||
due_odometer = work_item.next_due_odometer
|
||||
else:
|
||||
due_odometer = base_odometer + interval_km
|
||||
due_date = work_item.next_due_date or (base_date + timedelta(days=interval_months * 30))
|
||||
else:
|
||||
due_odometer = base_odometer + interval_km
|
||||
due_date = base_date + timedelta(days=interval_months * 30)
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(MaintenanceRecommendation).where(
|
||||
MaintenanceRecommendation.vehicle_id == car.id,
|
||||
MaintenanceRecommendation.recommendation_type == "oil_change",
|
||||
MaintenanceRecommendation.status == "active",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
priority = _recommendation_priority(car, due_odometer, due_date)
|
||||
payload = {
|
||||
"vehicle_id": car.id,
|
||||
"recommendation_type": "oil_change",
|
||||
"title": "Замена моторного масла",
|
||||
"description": "Плановая рекомендация по интервалу пробега и времени.",
|
||||
"due_odometer_km": due_odometer,
|
||||
"due_date": due_date,
|
||||
"priority": priority,
|
||||
"source": "mileage_interval",
|
||||
}
|
||||
if existing:
|
||||
for key, value in payload.items():
|
||||
setattr(existing, key, value)
|
||||
await session.flush()
|
||||
return existing
|
||||
recommendation = MaintenanceRecommendation(**payload)
|
||||
session.add(recommendation)
|
||||
await session.flush()
|
||||
return recommendation
|
||||
|
||||
|
||||
def money_to_float(value: Decimal | None) -> float:
|
||||
return float(value or 0)
|
||||
@@ -88,6 +88,28 @@ class ApiClient:
|
||||
async def public_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||
return await self.request("GET", "/api/service-centers/public", telegram_id=telegram_id)
|
||||
|
||||
async def sto_catalog(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||
return await self.request("GET", "/api/sto/catalog", telegram_id=telegram_id)
|
||||
|
||||
async def my_appointments(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||
return await self.request("GET", "/api/appointments/my", telegram_id=telegram_id)
|
||||
|
||||
async def sto_dashboard(self, telegram_id: int, service_center_id: int) -> dict[str, Any]:
|
||||
return await self.request(
|
||||
"GET",
|
||||
"/api/sto/dashboard",
|
||||
telegram_id=telegram_id,
|
||||
params={"service_center_id": service_center_id},
|
||||
)
|
||||
|
||||
async def sto_appointments(self, telegram_id: int, service_center_id: int) -> list[dict[str, Any]]:
|
||||
return await self.request(
|
||||
"GET",
|
||||
"/api/sto/appointments",
|
||||
telegram_id=telegram_id,
|
||||
params={"service_center_id": service_center_id, "status": "requested"},
|
||||
)
|
||||
|
||||
async def my_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||
return await self.request("GET", "/api/service-centers/my", telegram_id=telegram_id)
|
||||
|
||||
|
||||
62
bot/main.py
62
bot/main.py
@@ -271,7 +271,7 @@ async def analytics(message: Message) -> None:
|
||||
async def sto(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
centers = await api.public_service_centers(message.from_user.id)
|
||||
centers = await api.sto_catalog(message.from_user.id)
|
||||
except httpx.HTTPStatusError:
|
||||
centers = []
|
||||
if not centers:
|
||||
@@ -281,12 +281,66 @@ async def sto(message: Message) -> None:
|
||||
)
|
||||
return
|
||||
text = "Проверенные СТО:\n" + "\n".join(
|
||||
f"{item['id']}. {item.get('display_name') or item.get('name')} — {item.get('city') or 'город не указан'}"
|
||||
(
|
||||
f"{item['id']}. {item.get('display_name') or item.get('name')} — {item.get('city') or 'город не указан'}"
|
||||
f"{' · ближайшее окно ' + item['nearest_slot_at'][:16].replace('T', ' ') if item.get('nearest_slot_at') else ''}"
|
||||
)
|
||||
for item in centers[:10]
|
||||
)
|
||||
await message.answer(text, reply_markup=webapp_inline_keyboard("Каталог СТО"))
|
||||
|
||||
|
||||
@dp.message(Command("appointments"))
|
||||
async def appointments(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
items = await api.my_appointments(message.from_user.id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Записи не загрузились: {error.response.text}")
|
||||
return
|
||||
if not items:
|
||||
await message.answer(
|
||||
"Активных записей пока нет. В Mini App можно выбрать авто, СТО и свободное окно.",
|
||||
reply_markup=webapp_inline_keyboard("Записаться в СТО"),
|
||||
)
|
||||
return
|
||||
lines = ["Ваши записи:"]
|
||||
for item in items[:10]:
|
||||
lines.append(
|
||||
f"#{item['id']} {item['service_name']} — {item['requested_start_at'][:16].replace('T', ' ')} · {item['status']}"
|
||||
)
|
||||
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Мои записи"))
|
||||
|
||||
|
||||
@dp.message(Command("sto_bookings"))
|
||||
async def sto_bookings(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
centers = await api.my_service_centers(message.from_user.id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Кабинет СТО не доступен: {error.response.text}")
|
||||
return
|
||||
if not centers:
|
||||
await message.answer("У вас пока нет СТО. Подайте заявку через /register_sto или Mini App.")
|
||||
return
|
||||
center = centers[0]
|
||||
try:
|
||||
dashboard = await api.sto_dashboard(message.from_user.id, center["id"])
|
||||
pending = await api.sto_appointments(message.from_user.id, center["id"])
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Заявки СТО не загрузились: {error.response.text}")
|
||||
return
|
||||
lines = [
|
||||
f"Кабинет СТО: {center.get('display_name') or center.get('name')}",
|
||||
f"Авто: {dashboard['connected_vehicles']}",
|
||||
f"Новые заявки: {dashboard['pending_appointments']}",
|
||||
f"Подтверждено: {dashboard['confirmed_appointments']}",
|
||||
]
|
||||
for item in pending[:8]:
|
||||
lines.append(f"#{item['id']} {item['service_name']} — {item['requested_start_at'][:16].replace('T', ' ')}")
|
||||
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Кабинет СТО"))
|
||||
|
||||
|
||||
@dp.message(Command("register_sto"))
|
||||
async def register_sto(message: Message, command: CommandObject) -> None:
|
||||
await upsert(message)
|
||||
@@ -464,7 +518,11 @@ async def help_message(message: Message) -> None:
|
||||
"• /insurance, /tax, /fine — регулярные и разовые расходы;\n"
|
||||
"• /analytics — стоимость владения и расход;\n"
|
||||
"• /sto — каталог проверенных СТО;\n"
|
||||
"• /appointments — мои записи в СТО;\n"
|
||||
"• /sto_bookings — заявки и календарь для владельца СТО;\n"
|
||||
"• /register_sto — заявка на СТО.\n\n"
|
||||
"Для ТО: в карточке авто Mini App показывает рекомендации, доступные СТО, свободные окна, запись и согласование времени.\n"
|
||||
"Для СТО: настрой график, принимай заявки, создавай заказ-наряд из подтвержденной записи и отправляй клиенту результат работ.\n\n"
|
||||
"Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.",
|
||||
reply_markup=menu_inline_keyboard(),
|
||||
)
|
||||
|
||||
266
tests/test_sto_booking.py
Normal file
266
tests/test_sto_booking.py
Normal file
@@ -0,0 +1,266 @@
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def next_weekday(target: int) -> date:
|
||||
today = date.today()
|
||||
delta = (target - today.weekday()) % 7
|
||||
if delta == 0:
|
||||
delta = 7
|
||||
return today + timedelta(days=delta)
|
||||
|
||||
|
||||
async def create_verified_center(client, owner_headers, admin_headers, internal_headers, *, city: str = "Seoul") -> dict:
|
||||
center = (
|
||||
await client.post(
|
||||
"/api/service-centers",
|
||||
headers=owner_headers,
|
||||
json={
|
||||
"display_name": f"Booking Service {city}",
|
||||
"country": "KR",
|
||||
"city": city,
|
||||
"specializations": ["oil_change", "diagnostics"],
|
||||
},
|
||||
)
|
||||
).json()
|
||||
await client.post(
|
||||
"/api/users",
|
||||
headers=internal_headers,
|
||||
json={"telegram_id": 9001, "platform_role": "admin"},
|
||||
)
|
||||
response = await client.post(f"/api/admin/service-centers/{center['id']}/verify", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sto_catalog_shows_only_approved_and_filters_city(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
approved = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers, city="Seoul")
|
||||
await client.post(
|
||||
"/api/service-centers",
|
||||
headers=auth_headers,
|
||||
json={"display_name": "Pending Booking Service", "country": "KR", "city": "Busan"},
|
||||
)
|
||||
|
||||
response = await client.get("/api/sto/catalog?city=Seoul", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
ids = [item["id"] for item in response.json()]
|
||||
assert ids == [approved["id"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_slots_skip_weekend_lunch_and_holidays(
|
||||
client, auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers)
|
||||
monday = next_weekday(0)
|
||||
saturday = next_weekday(5)
|
||||
await client.post(
|
||||
"/api/sto/settings/booking",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"service_center_id": center["id"],
|
||||
"working_days": [0],
|
||||
"open_time": "09:00:00",
|
||||
"close_time": "13:00:00",
|
||||
"lunch_break_start": "11:00:00",
|
||||
"lunch_break_end": "12:00:00",
|
||||
"slot_duration_minutes": 60,
|
||||
"max_parallel_bookings": 1,
|
||||
"accepts_online_booking": True,
|
||||
},
|
||||
)
|
||||
await client.post(
|
||||
"/api/sto/settings/holidays",
|
||||
headers=auth_headers,
|
||||
json={"service_center_id": center["id"], "holiday_date": saturday.isoformat(), "reason": "Closed"},
|
||||
)
|
||||
|
||||
monday_slots = (
|
||||
await client.get(
|
||||
f"/api/sto/{center['id']}/available-slots?date_from={monday}&date_to={monday}&duration_minutes=60",
|
||||
headers=auth_headers,
|
||||
)
|
||||
).json()
|
||||
saturday_slots = (
|
||||
await client.get(
|
||||
f"/api/sto/{center['id']}/available-slots?date_from={saturday}&date_to={saturday}&duration_minutes=60",
|
||||
headers=auth_headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
assert {datetime.fromisoformat(slot["start_at"]).time() for slot in monday_slots} == {time(9, 0), time(10, 0), time(12, 0)}
|
||||
assert saturday_slots == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notifications(
|
||||
client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers)
|
||||
workday = next_weekday(0)
|
||||
start_at = datetime.combine(workday, time(10, 0), tzinfo=UTC)
|
||||
proposed_at = datetime.combine(workday, time(14, 0), tzinfo=UTC)
|
||||
await client.post(
|
||||
"/api/sto/settings/booking",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"service_center_id": center["id"],
|
||||
"working_days": [0, 1, 2, 3, 4],
|
||||
"open_time": "09:00:00",
|
||||
"close_time": "18:00:00",
|
||||
"slot_duration_minutes": 60,
|
||||
"max_parallel_bookings": 1,
|
||||
"accepts_online_booking": True,
|
||||
},
|
||||
)
|
||||
vehicle = (
|
||||
await client.post(
|
||||
"/api/my/vehicles",
|
||||
headers=other_auth_headers,
|
||||
json={"name": "Client car", "current_odometer": 45000, "oil_change_interval_km": 10000},
|
||||
)
|
||||
).json()
|
||||
|
||||
past_response = await client.post(
|
||||
"/api/appointments",
|
||||
headers=other_auth_headers,
|
||||
json={
|
||||
"service_center_id": center["id"],
|
||||
"vehicle_id": vehicle["id"],
|
||||
"service_type": "oil_change",
|
||||
"service_name": "Oil change",
|
||||
"requested_start_at": (datetime.now(UTC) - timedelta(days=1)).isoformat(),
|
||||
"estimated_duration_minutes": 60,
|
||||
},
|
||||
)
|
||||
assert past_response.status_code == 409
|
||||
|
||||
created = await client.post(
|
||||
"/api/appointments",
|
||||
headers=other_auth_headers,
|
||||
json={
|
||||
"service_center_id": center["id"],
|
||||
"vehicle_id": vehicle["id"],
|
||||
"service_type": "oil_change",
|
||||
"service_name": "Oil change",
|
||||
"requested_start_at": start_at.isoformat(),
|
||||
"estimated_duration_minutes": 60,
|
||||
"customer_comment": "Need synthetic oil",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
appointment = created.json()
|
||||
assert appointment["status"] == "requested"
|
||||
|
||||
duplicate = await client.post(
|
||||
"/api/appointments",
|
||||
headers=other_auth_headers,
|
||||
json={
|
||||
"service_center_id": center["id"],
|
||||
"vehicle_id": vehicle["id"],
|
||||
"service_type": "diagnostics",
|
||||
"service_name": "Diagnostics",
|
||||
"requested_start_at": start_at.isoformat(),
|
||||
"estimated_duration_minutes": 60,
|
||||
},
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
|
||||
proposed = await client.post(
|
||||
f"/api/sto/appointments/{appointment['id']}/propose-time",
|
||||
headers=auth_headers,
|
||||
json={"proposed_start_at": proposed_at.isoformat(), "comment": "Better window"},
|
||||
)
|
||||
assert proposed.status_code == 200
|
||||
assert proposed.json()["status"] == "proposed_new_time"
|
||||
|
||||
accepted = await client.post(
|
||||
f"/api/appointments/{appointment['id']}/accept-proposed-time",
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
assert accepted.status_code == 200
|
||||
assert accepted.json()["status"] == "confirmed"
|
||||
|
||||
calendar = await client.get(
|
||||
f"/api/sto/calendar?service_center_id={center['id']}&date_from={workday.isoformat()}T00:00:00Z&date_to={workday.isoformat()}T23:59:59Z",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert calendar.status_code == 200
|
||||
assert calendar.json()[0]["id"] == appointment["id"]
|
||||
|
||||
work_order = await client.post(
|
||||
f"/api/sto/appointments/{appointment['id']}/create-work-order",
|
||||
headers=auth_headers,
|
||||
json={"odometer": 45100},
|
||||
)
|
||||
assert work_order.status_code == 201
|
||||
assert work_order.json()["vehicle_id"] == vehicle["id"]
|
||||
|
||||
my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers)
|
||||
assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recommendations_can_be_generated_dismissed_and_linked_to_booking(
|
||||
client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers
|
||||
) -> None:
|
||||
center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers)
|
||||
workday = next_weekday(1)
|
||||
start_at = datetime.combine(workday, time(10, 0), tzinfo=UTC)
|
||||
vehicle = (
|
||||
await client.post(
|
||||
"/api/my/vehicles",
|
||||
headers=other_auth_headers,
|
||||
json={"name": "Recommendation car", "current_odometer": 90000, "oil_change_interval_km": 10000},
|
||||
)
|
||||
).json()
|
||||
|
||||
recommendations = await client.get(
|
||||
f"/api/vehicles/{vehicle['id']}/maintenance-recommendations",
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
assert recommendations.status_code == 200
|
||||
oil_recommendation = recommendations.json()[0]
|
||||
assert oil_recommendation["recommendation_type"] == "oil_change"
|
||||
|
||||
appointment = (
|
||||
await client.post(
|
||||
"/api/appointments",
|
||||
headers=other_auth_headers,
|
||||
json={
|
||||
"service_center_id": center["id"],
|
||||
"vehicle_id": vehicle["id"],
|
||||
"service_type": "oil_change",
|
||||
"service_name": "Oil change",
|
||||
"requested_start_at": start_at.isoformat(),
|
||||
"estimated_duration_minutes": 60,
|
||||
"source_recommendation_id": oil_recommendation["id"],
|
||||
},
|
||||
)
|
||||
).json()
|
||||
booked = await client.post(
|
||||
f"/api/maintenance-recommendations/{oil_recommendation['id']}/book",
|
||||
headers=other_auth_headers,
|
||||
json={"appointment_id": appointment["id"]},
|
||||
)
|
||||
assert booked.status_code == 200
|
||||
assert booked.json()["status"] == "booked"
|
||||
|
||||
custom = (
|
||||
await client.post(
|
||||
f"/api/vehicles/{vehicle['id']}/maintenance-recommendations",
|
||||
headers=other_auth_headers,
|
||||
json={"recommendation_type": "brakes", "title": "Check brakes", "priority": "low"},
|
||||
)
|
||||
).json()
|
||||
dismissed = await client.post(
|
||||
f"/api/maintenance-recommendations/{custom['id']}/dismiss",
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
assert dismissed.status_code == 200
|
||||
assert dismissed.json()["status"] == "dismissed"
|
||||
@@ -254,9 +254,12 @@
|
||||
<button class="menu-row" data-menu-section="taxSection">Налоги</button>
|
||||
<button class="menu-row" data-menu-section="fineSection">Штрафы</button>
|
||||
<button class="menu-row" data-menu-section="publicServicesSection">СТО</button>
|
||||
<button class="menu-row" data-menu-section="appointmentsSection">Мои записи</button>
|
||||
<button class="menu-row" data-menu-section="maintenanceRecommendationsSection">Рекомендации ТО</button>
|
||||
<button class="menu-row" data-menu-section="reviewsSection">Отзывы</button>
|
||||
<button class="menu-row" data-menu-section="confirmationsSection">Подтверждения</button>
|
||||
<button class="menu-row" data-menu-section="connectedServicesSection">Подключённые СТО</button>
|
||||
<button class="menu-row" data-menu-section="stoCalendarSection">Календарь СТО</button>
|
||||
<button class="menu-row admin-only hidden" data-menu-section="adminSection">Админ</button>
|
||||
<button class="menu-row" data-menu-section="settingsSection">Настройки</button>
|
||||
|
||||
@@ -405,6 +408,18 @@
|
||||
<div id="serviceReviews" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="appointmentsSection">
|
||||
<h2>Мои записи</h2>
|
||||
<div class="tip-card">Здесь видны заявки, подтвержденные записи и предложения нового времени от СТО.</div>
|
||||
<div id="appointmentsList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="maintenanceRecommendationsSection">
|
||||
<h2>Рекомендации ТО</h2>
|
||||
<div class="tip-card">CarPass подсказывает обслуживание по пробегу, времени и истории работ.</div>
|
||||
<div id="maintenanceRecommendations" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="settingsSection">
|
||||
<h2>Настройки</h2>
|
||||
<form id="settingsForm" class="grid-form drawer-form">
|
||||
@@ -512,6 +527,13 @@
|
||||
<div id="serviceCentersList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="stoCalendarSection">
|
||||
<h2>Календарь СТО</h2>
|
||||
<div class="tip-card">Краткая сводка заявок и подтвержденных записей по первому доступному СТО.</div>
|
||||
<div id="stoDashboardSummary" class="stats mini-stats"></div>
|
||||
<div id="stoCalendarList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="drawer-section hidden" id="adminSection">
|
||||
<h2>Модерация СТО</h2>
|
||||
<div class="tip-card">Заявки видны только администраторам и модераторам.</div>
|
||||
|
||||
@@ -319,6 +319,9 @@ const state = {
|
||||
analytics: null,
|
||||
serviceCenters: [],
|
||||
publicServiceCenters: [],
|
||||
appointments: [],
|
||||
maintenanceRecommendations: [],
|
||||
stoCalendar: [],
|
||||
confirmations: null,
|
||||
connectedServices: [],
|
||||
adminPendingServices: [],
|
||||
@@ -1060,7 +1063,7 @@ async function loadPublicServiceCenters() {
|
||||
const root = document.querySelector("#publicServiceCenters");
|
||||
if (!root) return;
|
||||
try {
|
||||
const centers = await api("/service-centers/public");
|
||||
const centers = await api("/sto/catalog");
|
||||
state.publicServiceCenters = centers;
|
||||
root.innerHTML = centers.length
|
||||
? centers
|
||||
@@ -1070,7 +1073,7 @@ async function loadPublicServiceCenters() {
|
||||
<strong>${center.display_name || center.name}</strong>
|
||||
<small>${[center.city, center.address].filter(Boolean).join(", ") || "Адрес не указан"}</small>
|
||||
<small>${center.specializations?.join(", ") || "Специализация не указана"}</small>
|
||||
<span class="trust-badge">${center.rating_avg ? `★ ${center.rating_avg}` : "Проверка пройдена"}</span>
|
||||
<span class="trust-badge">${center.nearest_slot_at ? `Окно ${formatDateTime(center.nearest_slot_at)}` : center.rating_avg ? `★ ${center.rating_avg}` : "Проверка пройдена"}</span>
|
||||
</button>
|
||||
`,
|
||||
)
|
||||
@@ -1124,6 +1127,33 @@ async function openServiceCard(serviceCenterId) {
|
||||
<div class="service-actions">
|
||||
<button type="button" class="ghost-btn" id="attachServiceBtn">Привязать выбранное авто</button>
|
||||
</div>
|
||||
<form class="grid-form drawer-form" id="serviceBookingForm">
|
||||
<label>
|
||||
Услуга
|
||||
<select name="service_type">
|
||||
<option value="oil_change">Замена масла</option>
|
||||
<option value="diagnostics">Диагностика</option>
|
||||
<option value="maintenance">ТО</option>
|
||||
<option value="tire_service">Шиномонтаж</option>
|
||||
<option value="brakes">Тормоза</option>
|
||||
<option value="repair">Ремонт</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Дата
|
||||
<input name="date" type="date" value="${today()}" />
|
||||
</label>
|
||||
<label>
|
||||
Свободное окно
|
||||
<select name="slot" id="bookingSlotSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
Комментарий
|
||||
<input name="customer_comment" placeholder="Что нужно сделать" />
|
||||
</label>
|
||||
<button type="submit">Записаться</button>
|
||||
</form>
|
||||
<form class="grid-form drawer-form" id="serviceReviewForm">
|
||||
<label>
|
||||
Оценка
|
||||
@@ -1182,9 +1212,212 @@ async function openServiceCard(serviceCenterId) {
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
const bookingForm = card.querySelector("#serviceBookingForm");
|
||||
const reloadSlots = () => loadServiceBookingSlots(serviceCenterId, bookingForm);
|
||||
bookingForm.querySelector('[name="service_type"]').addEventListener("change", reloadSlots);
|
||||
bookingForm.querySelector('[name="date"]').addEventListener("change", reloadSlots);
|
||||
bookingForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedCarId) {
|
||||
toast("Выбери автомобиль", "error");
|
||||
return;
|
||||
}
|
||||
const data = formData(bookingForm);
|
||||
if (!data.slot) {
|
||||
toast("Выбери свободное окно", "error");
|
||||
return;
|
||||
}
|
||||
await runAction(bookingForm.querySelector('button[type="submit"]'), "Создаю запись...", async () => {
|
||||
await api("/appointments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
service_center_id: serviceCenterId,
|
||||
vehicle_id: state.selectedCarId,
|
||||
service_type: data.service_type,
|
||||
service_name: bookingServiceName(data.service_type),
|
||||
requested_start_at: data.slot,
|
||||
customer_comment: data.customer_comment || null,
|
||||
}),
|
||||
});
|
||||
await loadAppointments();
|
||||
toast("Заявка отправлена в СТО");
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
await reloadSlots();
|
||||
card.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function bookingServiceName(type) {
|
||||
const names = {
|
||||
oil_change: "Замена масла",
|
||||
diagnostics: "Диагностика",
|
||||
maintenance: "ТО",
|
||||
tire_service: "Шиномонтаж",
|
||||
brakes: "Тормоза",
|
||||
repair: "Ремонт",
|
||||
other: "Другое",
|
||||
};
|
||||
return names[type] || "Обслуживание";
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " ");
|
||||
return date.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
async function loadServiceBookingSlots(serviceCenterId, form) {
|
||||
const select = form.querySelector("#bookingSlotSelect");
|
||||
const serviceType = form.querySelector('[name="service_type"]').value;
|
||||
const date = form.querySelector('[name="date"]').value || today();
|
||||
select.innerHTML = `<option value="">Загружаю...</option>`;
|
||||
try {
|
||||
const slots = await api(`/sto/${serviceCenterId}/available-slots?service_type=${encodeURIComponent(serviceType)}&date_from=${date}&date_to=${date}`);
|
||||
select.innerHTML = slots.length
|
||||
? slots.map((slot) => `<option value="${slot.start_at}">${formatDateTime(slot.start_at)}</option>`).join("")
|
||||
: `<option value="">Нет свободных окон</option>`;
|
||||
} catch (error) {
|
||||
select.innerHTML = `<option value="">Слоты не загрузились</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
const root = document.querySelector("#appointmentsList");
|
||||
if (!root) return;
|
||||
try {
|
||||
state.appointments = await api("/appointments/my");
|
||||
root.innerHTML = state.appointments.length
|
||||
? state.appointments.map((item) => `
|
||||
<div class="stack-item">
|
||||
<strong>${item.service_name}</strong>
|
||||
<small>${formatDateTime(item.confirmed_start_at || item.proposed_start_at || item.requested_start_at)}</small>
|
||||
<span class="trust-badge">${item.status}</span>
|
||||
${item.status === "proposed_new_time" ? `
|
||||
<div class="service-actions">
|
||||
<button type="button" data-accept-appointment="${item.id}">Принять время</button>
|
||||
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Записей пока нет</div>`;
|
||||
root.querySelectorAll("[data-accept-appointment]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Сохраняю...", async () => {
|
||||
await api(`/appointments/${button.dataset.acceptAppointment}/accept-proposed-time`, { method: "POST" });
|
||||
await loadAppointments();
|
||||
}));
|
||||
});
|
||||
root.querySelectorAll("[data-reject-appointment]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Сохраняю...", async () => {
|
||||
await api(`/appointments/${button.dataset.rejectAppointment}/reject-proposed-time`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comment: "Отклонено в Mini App" }),
|
||||
});
|
||||
await loadAppointments();
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="empty">Записи не загрузились</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMaintenanceRecommendations() {
|
||||
const root = document.querySelector("#maintenanceRecommendations");
|
||||
if (!root) return;
|
||||
if (!state.selectedCarId) {
|
||||
root.innerHTML = `<div class="empty">Выбери автомобиль</div>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.maintenanceRecommendations = await api(`/vehicles/${state.selectedCarId}/maintenance-recommendations`);
|
||||
root.innerHTML = state.maintenanceRecommendations.length
|
||||
? state.maintenanceRecommendations.map((item) => `
|
||||
<div class="stack-item">
|
||||
<strong>${item.title}</strong>
|
||||
<small>${item.description || "Плановое обслуживание"}</small>
|
||||
<small>${[item.due_odometer_km ? `${item.due_odometer_km} км` : "", item.due_date || ""].filter(Boolean).join(" · ")}</small>
|
||||
<span class="trust-badge">${item.priority} · ${item.status}</span>
|
||||
${item.status === "active" ? `<button type="button" class="ghost-btn" data-dismiss-recommendation="${item.id}">Скрыть</button>` : ""}
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Рекомендаций пока нет</div>`;
|
||||
root.querySelectorAll("[data-dismiss-recommendation]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Скрываю...", async () => {
|
||||
await api(`/maintenance-recommendations/${button.dataset.dismissRecommendation}/dismiss`, { method: "POST" });
|
||||
await loadMaintenanceRecommendations();
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
root.innerHTML = `<div class="empty">Рекомендации не загрузились</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStoCalendar() {
|
||||
const summary = document.querySelector("#stoDashboardSummary");
|
||||
const list = document.querySelector("#stoCalendarList");
|
||||
if (!summary || !list) return;
|
||||
try {
|
||||
if (!state.serviceCenters.length) {
|
||||
const centers = await api("/service-centers/my");
|
||||
state.serviceCenters = centers;
|
||||
}
|
||||
const center = state.serviceCenters[0];
|
||||
if (!center) {
|
||||
summary.innerHTML = "";
|
||||
list.innerHTML = `<div class="empty">СТО пока не создано</div>`;
|
||||
return;
|
||||
}
|
||||
const [dashboard, appointments] = await Promise.all([
|
||||
api(`/sto/dashboard?service_center_id=${center.id}`),
|
||||
api(`/sto/calendar?service_center_id=${center.id}`),
|
||||
]);
|
||||
summary.innerHTML = `
|
||||
<div class="stat-card"><span>Авто</span><strong>${dashboard.connected_vehicles}</strong></div>
|
||||
<div class="stat-card"><span>Новые заявки</span><strong>${dashboard.pending_appointments}</strong></div>
|
||||
<div class="stat-card"><span>Подтверждено</span><strong>${dashboard.confirmed_appointments}</strong></div>
|
||||
<div class="stat-card"><span>Месяц</span><strong>${money(dashboard.revenue_month || 0)}</strong></div>
|
||||
`;
|
||||
list.innerHTML = appointments.length
|
||||
? appointments.map((item) => `
|
||||
<div class="stack-item">
|
||||
<strong>${item.service_name}</strong>
|
||||
<small>${formatDateTime(item.confirmed_start_at || item.requested_start_at)} · авто #${item.vehicle_id}</small>
|
||||
<span class="trust-badge">${item.status}</span>
|
||||
${item.status === "requested" ? `
|
||||
<div class="service-actions">
|
||||
<button type="button" data-confirm-sto-appointment="${item.id}">Подтвердить</button>
|
||||
<button type="button" class="ghost-btn" data-reject-sto-appointment="${item.id}">Отклонить</button>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">Записей на ближайший период нет</div>`;
|
||||
list.querySelectorAll("[data-confirm-sto-appointment]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Подтверждаю...", async () => {
|
||||
await api(`/sto/appointments/${button.dataset.confirmStoAppointment}/confirm`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comment: "Подтверждено в Mini App" }),
|
||||
});
|
||||
await loadStoCalendar();
|
||||
}));
|
||||
});
|
||||
list.querySelectorAll("[data-reject-sto-appointment]").forEach((button) => {
|
||||
button.addEventListener("click", () => runAction(button, "Отклоняю...", async () => {
|
||||
await api(`/sto/appointments/${button.dataset.rejectStoAppointment}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comment: "Отклонено в Mini App" }),
|
||||
});
|
||||
await loadStoCalendar();
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
summary.innerHTML = "";
|
||||
list.innerHTML = `<div class="empty">Календарь СТО не загрузился</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function trustLabel(level) {
|
||||
const labels = {
|
||||
new_service: "Новый сервис",
|
||||
@@ -2024,6 +2257,9 @@ async function openDrawerSection(sectionId, options = {}) {
|
||||
if (sectionId === "connectedServicesSection") await loadConnectedServices();
|
||||
if (sectionId === "servicePanelSection") await loadServiceCenters();
|
||||
if (sectionId === "publicServicesSection") await loadPublicServiceCenters();
|
||||
if (sectionId === "appointmentsSection") await loadAppointments();
|
||||
if (sectionId === "maintenanceRecommendationsSection") await loadMaintenanceRecommendations();
|
||||
if (sectionId === "stoCalendarSection") await loadStoCalendar();
|
||||
if (sectionId === "reviewsSection") renderServiceReviews();
|
||||
if (sectionId === "adminSection") await loadAdminPendingServices();
|
||||
if (options.expenseCategory) {
|
||||
|
||||
Reference in New Issue
Block a user