В прошлом посте я рассказал, как с нуля написал систему онлайн-записи для автосервиса. Сегодня разберем, из каких модулей она состоит и как они взаимодействуют. Спойлер: там есть пара нестандартных решений, которые я не встречал в других системах.
Архитектура: все по полочкам
Вся система построена на фреймворке Flask — это такой микро-фреймворк для Python, который позволяет быстро создавать веб-приложения. Как работает веб-приложение на Flask? Самый простой путь — запускать его на хостинге как CGI-приложение, но Beget предлагает более удобный способ через Passenger. Я выбрал второй вариант.
Мое приложение состоит из следующих модулей:
Это основа основ — файл, который описывает все таблицы в базе данных. Я использовал SQLite, потому что это не требует отдельного сервера и идеально подходит для моего проекта.
Самое интересное здесь — структура боксов. В YClients была просто запись на время, а мне нужно было разделить места в мастерской: есть обычный "Бокс услуг", есть "Бокс эстакады" для подвески, и есть "Бокс подъемников", который внутри делится на 4 отдельных места (Подъемник 1, Подъемник 2, Рабочее место 1, Рабочее место 2).
Вот это поле parent_id — ключевое. Оно позволяет создавать вложенность: один бокс может быть родителем для других. Так "Бокс подъемников" стал родителем для "Подъемник 1", "Подъемник 2" и так далее.
Это нестандартное решение, потому что в типовых системах обычно просто перечисляют все места записи плоским списком. А тут — иерархия!
2. Модуль бизнес-логики (app.py)
Самый большой файл — больше 1000 строк. Здесь все маршруты, обработчики запросов и связь между клиентской частью и базой данных.
- /api/available-slots — ищет свободные слоты для записи. Сначала проверяет все боксы, затем исключает занятые и заблокированные. Учитывает скидки и автоматически назначает бокс, если для услуги он задан по умолчанию.
- /booking — создает запись, сохраняет в базу и запускает уведомления.
- /operator/* — панель управления (все админские штуки защищены декоратором @Operator_required).
3. Модуль уведомлений (встроен в app.py)
Отдельного файла для уведомлений нет, но логика там серьезная. Есть две функции:
send_telegram_notification() — отправляет оператору сообщение в Telegram. Формирует красивое HTML-сообщение с деталями записи.
send_client_email() — отправляет клиенту письмо с подтверждением записи. Там целый HTML-шаблон с логотипом, деталями и важными инструкциями. Используется SMTP-сервер Mail.ru — я завел отдельную почту специально для этих целей.
Интересная деталь: я сделал так, что если клиент не указал email, письмо просто не отправляется. Это позволяет не спамить тем, кто не хочет получать уведомления.
4. Модуль парсинга и анализа конкурентов (отдельная система)
Этот модуль живет отдельно от основного приложения, но данные из него я использую для настройки цен и услуг. Он работает так:
- Собирает данные с Яндекс.Карт и 2ГИС — названия, адреса, телефоны, сайты.
- Парсит отзывы с этих карт и социальных сетей.
- Собирает статистику с Авито — какие услуги предлагают конкуренты и сколько они зарабатывают.
- Использует Яндекс Вордстат — чтобы понять, что ищут клиенты (например, "замена тормозных колодок цена").
- Передает все это в нейросеть DeepSeek через API, и та выдает резюме по каждому конкуренту — услуги, цены, отзывы, доля рынка.
5. Планировщик динамических скидок
Это отдельный демон, который запускается вместе с приложением. Он использует библиотеку APScheduler и делает две вещи:
- Ежедневно в 05:00 — генерирует скидки на основании прогноза погоды и текущей загрузки.
- Каждый час — корректирует скидки, если изменилась ситуация.
Как работает логика скидок? Если на улице мороз или дождь, люди реже едут в сервис. Значит, нужно их привлечь скидкой. Если загрузка низкая — тоже скидка. В итоге у меня есть система, которая в реальном времени подстраивает цены под спрос.
Нестандартные решения, которые я применил
1. Автоматический выбор бокса по умолчанию
Когда клиент выбирает услугу, система автоматически назначает бокс. Например, если выбрали "Ремонт тормозной системы", автоматически назначается "Подъемник 1". Клиент вообще не видит выбор бокса — это решается на бекенде.
Я сделал так, потому что в YClients клиент выбирал время, а потом мучился с выбором мастера, места и т.д. У меня все просто: клиент выбирает услугу и время — система сама понимает, где это можно сделать.
2. Иерархия боксов через self-referencing foreign key
Я уже упоминал это решение — поле parent_id, которое ссылается на ту же таблицу work_bay. Это позволяет строить деревья любой вложенности. По сути, я реализовал структуру "категория → подкатегория → элемент" прямо внутри одной таблицы.
Это позволило мне создать группу "Бокс подъемников" и вложить в нее четыре дочерних места. При этом оператор может в один клик отключить всю группу или отключить отдельное место.
3. Двойной API для дополнительных услуг
Обычно список дополнительных услуг грузится прямо в карточке основной услуги. Я пошел дальше: сделал отдельную страницу /operator/services/<id>/addons, где оператор может в удобном интерфейсе добавлять, редактировать и сортировать доп. услуги, а для клиентской части сделал отдельный API-эндпоинт, который отдает только активные услуги в правильном порядке.
Такой подход позволил разделить управление и отображение. Оператору удобно, клиенту быстро.
4. Отслеживание ухода клиентов с сайта
Я добавил модуль статистики, который записывает, на каком этапе клиент бросил запись. Если человек начал выбирать услугу, но через 60 секунд бездействия закрыл вкладку — появляется опрос "Почему вы не записались?". Это помогает улучшать интерфейс.
Как все это связано между собой
1. Клиент заходит на сайт, выбирает мастерскую, услугу, дату и время.
2. Модуль app.py ищет свободные слоты в БД (с учетом боксов и скидок).
3. Если клиент оставляет заявку — создается запись в БД, модуль уведомлений отправляет письмо клиенту и Telegram-сообщение оператору.
4. Планировщик каждую ночь анализирует загрузку и прогноз погоды и обновляет скидки.
5. Модуль анализа конкурентов собирает данные и помогает корректировать цены и услуги.
Сложности, с которыми я столкнулся
Написание кода — это половина дела. Вторая половина — заставить это работать на сервере.
- Настройка Beget — пришлось потратить время, чтобы разобраться с виртуальным окружением, правами на файлы и Passenger. Три раза переписывал конфигурацию!
- Кодировка — на сервере стояла locale с отличной от моей, из-за чего некоторые символы отображались кракозябрами. Лечится через export LC_ALL="en_US.UTF-8".
- SQLite на сервере — не хотел ставить PostgreSQL, потому что для моих объемов SQLite хватает за глаза. Но пришлось настроить права на запись в папку с БД.
В следующем посте расскажу про систему динамических скидок подробнее — как она работает, как настраивается и какие алгоритмы заложены. Если есть вопросы — задавайте в комментариях.