Legan Studio
Все статьи
~ 5 мин чтения

Бот для фитнес-клуба в MAX: расписание, запись, тренеры, абонементы

Как сделать бот для фитнес-клуба в MAX: расписание групповых занятий, запись с лимитом мест, абонементы и баланс посещений, тренеры, заморозка.

  • MAX
  • фитнес
  • вертикали

Фитнес-клуб — отличный кейс для бота в MAX: расписание меняется каждую неделю, запись на групповые с ограничением мест, баланс посещений по абонементу, заморозка, индивидуальные тренировки. Без бота — Excel + менеджер на телефоне, с ботом — клиент за 10 секунд видит, есть ли место на йогу в 19:00 и записывается одним тапом. В этой статье — модель данных, расписание, запись с лимитом, абонементы и заморозка, чат с тренером, push-напоминания, программа возвращения «отвалившихся» клиентов.

Что должен уметь бот

  1. Показывать расписание групповых занятий (день, неделя).
  2. Записывать с учётом лимита мест и waitlist.
  3. Хранить абонемент: тип, остаток посещений, срок действия.
  4. Поддерживать заморозку и продление.
  5. Запись на индивидуальные тренировки и массаж.
  6. Чат с тренером (live-chat).
  7. Уведомления: «занятие через час», «заняло освободилось место в waitlist», «абонемент кончается».
  8. Возврат клиентов, которые перестали ходить.

Модель данных

CREATE TABLE memberships (
    id BIGSERIAL PRIMARY KEY,
    customer_id BIGINT,
    type TEXT,                       -- monthly, 8-visits, year, ...
    visits_left INT,
    started_at DATE,
    expires_at DATE,
    is_frozen BOOLEAN DEFAULT false,
    frozen_until DATE
);

CREATE TABLE classes (
    id BIGSERIAL PRIMARY KEY,
    title TEXT,                      -- "Йога", "Кроссфит", ...
    trainer_id BIGINT,
    starts_at TIMESTAMPTZ,
    duration_min INT,
    capacity INT,                    -- лимит мест
    room TEXT
);

CREATE INDEX ix_classes_starts_at ON classes(starts_at);

CREATE TABLE bookings (
    id BIGSERIAL PRIMARY KEY,
    class_id BIGINT REFERENCES classes(id),
    customer_id BIGINT,
    status TEXT,                     -- booked, waitlisted, attended, cancelled
    booked_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (class_id, customer_id)
);

CREATE INDEX ix_bookings_class_status ON bookings(class_id, status);

Расписание

@bot.callback_query_handler(lambda c: c.data.startswith("sched:"))
async def show_schedule(call):
    _, day_offset = call.data.split(":")
    day = (datetime.now().date() + timedelta(days=int(day_offset)))
    classes = await db.fetch_all("""
        SELECT c.*, t.name AS trainer_name,
               COUNT(b.id) FILTER (WHERE b.status='booked') AS booked
        FROM classes c
        LEFT JOIN trainers t ON t.id = c.trainer_id
        LEFT JOIN bookings b ON b.class_id = c.id
        WHERE c.starts_at::date = $1
        GROUP BY c.id, t.name
        ORDER BY c.starts_at
    """, day)
    text = f"*Расписание {day:%A, %d.%m}*\n\n"
    rows = []
    for c in classes:
        free = max(0, c.capacity - c.booked)
        text += f"`{c.starts_at:%H:%M}` {c.title} ({c.trainer_name}) — {free}/{c.capacity}\n"
        rows.append([InlineKeyboardButton(
            f"{c.starts_at:%H:%M} {c.title}",
            callback_data=f"book:{c.id}",
        )])
    rows.append([
        InlineKeyboardButton("‹", callback_data=f"sched:{int(day_offset)-1}"),
        InlineKeyboardButton("›", callback_data=f"sched:{int(day_offset)+1}"),
    ])
    await bot.edit_message_text(call.message.chat.id, call.message.message_id, text,
                                reply_markup=InlineKeyboardMarkup(rows), parse_mode="Markdown")

Запись с лимитом и waitlist

@bot.callback_query_handler(lambda c: c.data.startswith("book:"))
async def on_book(call):
    class_id = int(call.data.split(":")[1])
    customer_id = await get_customer_id(call.from_user.id)
    membership = await get_active_membership(customer_id)
    if not membership or membership.visits_left <= 0:
        return await bot.answer_callback_query(call.id, "Нужен активный абонемент", show_alert=True)
    
    async with db.transaction():
        cls = await db.fetch_one("SELECT * FROM classes WHERE id = $1 FOR UPDATE", class_id)
        booked = await db.fetch_val("SELECT COUNT(*) FROM bookings WHERE class_id = $1 AND status = 'booked'", class_id)
        if booked < cls.capacity:
            await db.execute("INSERT INTO bookings (class_id, customer_id, status) VALUES ($1,$2,'booked')",
                           class_id, customer_id)
            await deduct_visit(membership.id)
            text = "✅ Записаны"
        else:
            await db.execute("INSERT INTO bookings (class_id, customer_id, status) VALUES ($1,$2,'waitlisted')",
                           class_id, customer_id)
            text = "🕗 Мест нет, вы в листе ожидания"
    await bot.answer_callback_query(call.id, text, show_alert=True)

FOR UPDATE критично — без него два запроса в один момент могут оба пройти при последнем месте.

Освобождение места и waitlist promotion

При отмене бронирования:

async def cancel_booking(booking_id: int):
    async with db.transaction():
        booking = await db.fetch_one("UPDATE bookings SET status='cancelled' WHERE id=$1 RETURNING *", booking_id)
        await refund_visit(booking.customer_id)
        # promote waitlist
        nxt = await db.fetch_one("""
            SELECT * FROM bookings 
            WHERE class_id = $1 AND status = 'waitlisted'
            ORDER BY booked_at LIMIT 1 FOR UPDATE
        """, booking.class_id)
        if nxt:
            await db.execute("UPDATE bookings SET status='booked' WHERE id=$1", nxt.id)
            await deduct_visit_for(nxt.customer_id)
            await bot.send_message(
                user_id_for_customer(nxt.customer_id),
                f"🎉 Освободилось место на занятии в {format_class(booking.class_id)}. Вы записаны.",
            )

Абонементы и заморозка

Типы абонементов: безлимит на месяц, 8/12 посещений на N дней, годовой и т.п. Логика расчёта:

async def use_visit(membership_id: int) -> bool:
    m = await db.fetch_one("SELECT * FROM memberships WHERE id=$1 FOR UPDATE", membership_id)
    if m.is_frozen:
        return False
    if m.expires_at < date.today():
        return False
    if m.type == "unlimited":
        return True
    if m.visits_left <= 0:
        return False
    await db.execute("UPDATE memberships SET visits_left = visits_left - 1 WHERE id=$1", membership_id)
    return True

Заморозка — is_frozen=true + frozen_until сдвигает expires_at на ту же дельту:

async def freeze(membership_id: int, days: int):
    await db.execute("""
        UPDATE memberships SET 
            is_frozen = true,
            frozen_until = current_date + $2 * interval '1 day',
            expires_at = expires_at + $2 * interval '1 day'
        WHERE id = $1
    """, membership_id, days)

Cron автоматически снимает заморозку, когда frozen_until <= today.

Уведомления

  • За 2 часа до занятия — «Не забудьте! Йога в 19:00, зал 2».
  • За 30 минут — «Через полчаса, не забудьте форму».
  • При освобождении места в waitlist — мгновенно.
  • За 5 дней до окончания абонемента — «Скоро закончится, продлить?».
  • Если не приходит 14 дней — реактивация.
async def cron_pre_class():
    upcoming = await db.fetch_all("""
        SELECT b.id, b.customer_id, c.title, c.starts_at, c.room
        FROM bookings b JOIN classes c ON c.id = b.class_id
        WHERE b.status = 'booked'
          AND c.starts_at BETWEEN now() + interval '105 minutes' AND now() + interval '135 minutes'
          AND NOT b.notified_pre
    """)
    for b in upcoming:
        await bot.send_message(
            user_for_customer(b.customer_id),
            f"⏰ Через 2 часа: *{b.title}* в {b.starts_at:%H:%M} ({b.room})",
            parse_mode="Markdown",
        )
        await mark_notified_pre(b.id)

Чат с тренером

Каждый тренер привязан к чату в MAX. Сообщения от клиента «вопрос тренеру» переадресуются в этот чат:

@bot.message_handler(func=lambda m: state(m) == "chat:trainer")
async def on_to_trainer(msg):
    trainer = await get_assigned_trainer(msg.from_user.id)
    await bot.send_message(
        trainer.chat_id,
        f"Сообщение от клиента {msg.from_user.id}:\n\n{msg.text}",
        reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Ответить", callback_data=f"reply:{msg.from_user.id}")]]),
    )

Ответ тренера через reply-сообщение возвращается клиенту от имени бота.

Реактивация «отвалившихся»

async def cron_reactivation():
    targets = await db.fetch_all("""
        SELECT m.customer_id, m.type
        FROM memberships m
        LEFT JOIN bookings b ON b.customer_id = m.customer_id 
                            AND b.status = 'attended' 
                            AND b.attended_at > now() - interval '14 days'
        WHERE m.is_frozen = false 
          AND m.expires_at > current_date
          AND b.id IS NULL
          AND NOT EXISTS (SELECT 1 FROM reminders WHERE customer_id = m.customer_id 
                          AND kind='reactivation' AND sent_at > now() - interval '30 days')
    """)
    for t in targets:
        await bot.send_message(user_for_customer(t.customer_id),
            "Давно не виделись 💪 На этой неделе пробное занятие у нового тренера — записать?",
            reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Расписание", callback_data="sched:0")]]))

Интеграция с CRM фитнес-клубов

Популярные:

  • 1С:Фитнес-клуб;
  • Battlefield (RFM-сегментация);
  • 1Forma;
  • Mobifitness.

Большинство имеют REST или SOAP API для синхронизации абонементов, посещений, расписания. Минимум двусторонняя синхронизация:

  • расписание из CRM в бот раз в час;
  • бронирования из бота в CRM в реальном времени;
  • посещения (отмеченные на стойке/турникете) из CRM в бот.

Common pitfalls

  1. Запись без FOR UPDATE — overbooking.
  2. Не разморозили абонемент — клиент пришёл, не пускают.
  3. Слот не списан с баланса — клиент думал «месяц безлимит», а это 12 посещений.
  4. Уведомления каждые 30 минут — отписки.
  5. Тренер увольняется, привязка остаётся — сообщения уходят в никуда.

Итого

Бот для фитнес-клуба в MAX автоматизирует расписание с реальным остатком мест, запись с лимитом и waitlist (FOR UPDATE против overbooking), управление абонементами с поддержкой заморозки и продления, чат с тренером, уведомления (за 2 часа, освобождение waitlist, окончание абонемента), реактивацию ушедших клиентов. Интегрируется с 1С:Фитнес / Mobifitness / Battlefield по REST/SOAP. MVP — 4–6 недель и 700–1300 тыс. ₽; полная версия с тренерами, реактивацией, программой лояльности, мини-приложением для покупки абонементов — 8–12 недель и 1.8–3.5 млн ₽. Окупаемость для клуба от 500 активных абонементов — 3–6 месяцев за счёт снижения no-show на 25–35%, увеличения средней посещаемости и удержания.

Частые вопросы

Как избежать overbooking при записи на групповое занятие?

Используйте FOR UPDATE на запись класса в момент бронирования. В транзакции: SELECT ... FOR UPDATE класса, SELECT COUNT bookings WHERE status='booked', сравнение с capacity, INSERT booking — всё атомарно. Без FOR UPDATE два параллельных запроса оба прочитают «1 место свободно» и оба создадут booking. Дополнительно — UNIQUE constraint на (class_id, customer_id) защищает от двойной записи одного клиента.

Как реализовать waitlist?

При полном классе вместо отказа создаёте booking со статусом waitlisted. При cancel или no-show первого записанного — promote первого waitlisted в booked, отправляете push «освободилось место, вы записаны». Лимит waitlist — 5–10 человек, дальше «занятие полностью забронировано». Promotion должен быть атомарным с FOR UPDATE и refund visits предыдущему. На popular классы (силовая в 19:00) waitlist даёт реальную ценность — посещаемость растёт на 10–15%.

Как работает заморозка абонемента?

Поле is_frozen + frozen_until + expires_at смещается на N дней вперёд. Например, абонемент до 30 апреля, заморозка с 5 до 15 апреля (10 дней) → expires_at = 10 мая. Минимальный срок заморозки 7 дней, максимум — по правилам клуба (обычно 30–60 за весь срок абонемента). Cron автоматически снимает is_frozen при frozen_until ≤ today. Во время заморозки бот не даёт записаться, но показывает расписание (для планирования).

Какие уведомления полезны, а какие раздражают?

Полезны: за 2 часа до занятия (одно), за 30 минут (одно — если человек обычно опаздывает), освобождение места в waitlist (мгновенно), окончание абонемента за 5 дней. Раздражают: ежедневные «загляни в зал», push «новое расписание» каждую неделю, рассылки про БАДы и спортпит. Принцип — не больше 2 push в день и сегментация: новички получают одно, лояльные — другое, отвалившиеся — третье. CTR падает с 30% до 5% при ежедневной рассылке без сегментов.

Как организовать чат с тренером?

У каждого тренера — собственный чат в MAX (или отдельный сотруднический мессенджер). Сообщения клиента «вопрос тренеру» переадресуются в этот чат с metadata (имя, история тренировок, абонемент). Ответ тренера через reply возвращается клиенту от имени бота. Live-chat в рабочее время (8:00–22:00), вне — авто-ответ «вернёмся завтра». При увольнении тренера — все клиенты переназначаются на нового. Подробнее в статье «Live-chat с оператором в боте MAX».

Как реактивировать клиентов, которые перестали ходить?

Cron раз в неделю выявляет: активный абонемент, не приходил 14+ дней, не получал реактивацию последние 30 дней. Шлёт push «давно не виделись, на этой неделе пробное занятие — записать?». Конверсия — 8–15% по сравнению с массовой рассылкой 1–3%. Дополнительно — серия из 2–3 сообщений с интервалом 7 дней, разный контент (расписание / новый тренер / акция). После 60 дней без посещений — менеджер звонит лично. Для абонементов, которые истекают, реактивация за 5 дней до конца снижает churn на 30–40%.

Сколько стоит и сколько занимает разработка?

MVP с расписанием, записью с лимитом, абонементами, заморозкой, базовыми уведомлениями — 4–6 недель и 700–1300 тыс. ₽. Полная версия с waitlist, чатом с тренером, реактивацией, программой лояльности, мини-приложением для покупки абонементов и онлайн-оплаты — 8–12 недель и 1.8–3.5 млн ₽. Поддержка — 10–25 тыс. ₽/мес. Окупаемость для клуба 500+ активных абонементов — 3–6 месяцев за счёт снижения no-show, удержания и снижения нагрузки на ресепшен.