Фитнес-клуб — отличный кейс для бота в MAX: расписание меняется каждую неделю, запись на групповые с ограничением мест, баланс посещений по абонементу, заморозка, индивидуальные тренировки. Без бота — Excel + менеджер на телефоне, с ботом — клиент за 10 секунд видит, есть ли место на йогу в 19:00 и записывается одним тапом. В этой статье — модель данных, расписание, запись с лимитом, абонементы и заморозка, чат с тренером, push-напоминания, программа возвращения «отвалившихся» клиентов.
Что должен уметь бот
- Показывать расписание групповых занятий (день, неделя).
- Записывать с учётом лимита мест и waitlist.
- Хранить абонемент: тип, остаток посещений, срок действия.
- Поддерживать заморозку и продление.
- Запись на индивидуальные тренировки и массаж.
- Чат с тренером (live-chat).
- Уведомления: «занятие через час», «заняло освободилось место в waitlist», «абонемент кончается».
- Возврат клиентов, которые перестали ходить.
Модель данных
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
- Запись без FOR UPDATE — overbooking.
- Не разморозили абонемент — клиент пришёл, не пускают.
- Слот не списан с баланса — клиент думал «месяц безлимит», а это 12 посещений.
- Уведомления каждые 30 минут — отписки.
- Тренер увольняется, привязка остаётся — сообщения уходят в никуда.
Итого
Бот для фитнес-клуба в 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, удержания и снижения нагрузки на ресепшен.