From fec9635079ff95e81121ca46628ba6d507e47467 Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Fri, 15 May 2026 05:17:54 +0900 Subject: [PATCH] Add STO booking and maintenance automation --- README.md | 28 + .../202605150002_sto_booking_automation.py | 202 +++++ app/api/sto_booking.py | 696 ++++++++++++++++++ app/main.py | 2 + app/models/car.py | 118 ++- app/schemas/sto_booking.py | 187 +++++ app/services/sto_booking.py | 338 +++++++++ bot/api_client.py | 22 + bot/main.py | 62 +- tests/test_sto_booking.py | 266 +++++++ web/index.html | 22 + web/static/app.js | 240 +++++- 12 files changed, 2178 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/202605150002_sto_booking_automation.py create mode 100644 app/api/sto_booking.py create mode 100644 app/schemas/sto_booking.py create mode 100644 app/services/sto_booking.py create mode 100644 tests/test_sto_booking.py diff --git a/README.md b/README.md index 32e61c0..0340a6b 100644 --- a/README.md +++ b/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` — краткий отчет по заявкам и календарю СТО. ## Почему это полезно diff --git a/alembic/versions/202605150002_sto_booking_automation.py b/alembic/versions/202605150002_sto_booking_automation.py new file mode 100644 index 0000000..9564cc9 --- /dev/null +++ b/alembic/versions/202605150002_sto_booking_automation.py @@ -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") diff --git a/app/api/sto_booking.py b/app/api/sto_booking.py new file mode 100644 index 0000000..63fd756 --- /dev/null +++ b/app/api/sto_booking.py @@ -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 diff --git a/app/main.py b/app/main.py index 2a742cb..ddd1458 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/models/car.py b/app/models/car.py index 48a7547..a2c5707 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -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" diff --git a/app/schemas/sto_booking.py b/app/schemas/sto_booking.py new file mode 100644 index 0000000..cc71d05 --- /dev/null +++ b/app/schemas/sto_booking.py @@ -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] diff --git a/app/services/sto_booking.py b/app/services/sto_booking.py new file mode 100644 index 0000000..68e47c7 --- /dev/null +++ b/app/services/sto_booking.py @@ -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) diff --git a/bot/api_client.py b/bot/api_client.py index b6a7939..f779943 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -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) diff --git a/bot/main.py b/bot/main.py index d66cc2b..06d93f2 100644 --- a/bot/main.py +++ b/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(), ) diff --git a/tests/test_sto_booking.py b/tests/test_sto_booking.py new file mode 100644 index 0000000..b6f1fc5 --- /dev/null +++ b/tests/test_sto_booking.py @@ -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" diff --git a/web/index.html b/web/index.html index 11289f0..03e4526 100644 --- a/web/index.html +++ b/web/index.html @@ -254,9 +254,12 @@ + + + @@ -405,6 +408,18 @@
+ + + + + +