Сеть из 30 кофеен, 50 фитнес-клубов или 120 точек франшизы — это не «один бот для всех». Каждая точка имеет свой адрес, телефон, расписание, локальное меню, акции и собственника. Управляющая компания хочет единый стандарт качества, единый бренд и сквозную аналитику. Франчайзи — гибкость и независимость своей точки. В этой статье — как спроектировать бота в MAX, который решает обе задачи: единый UX, мультитенантная архитектура, выбор точки через геолокацию, локальные меню/расписания/акции, отдельный кабинет франчайзи, отчёты по точкам и роли в Бэкенде.
Архитектурный выбор: один бот vs много ботов
| Подход | Плюсы | Минусы |
|---|---|---|
| Один бот на всю сеть | единый brand handle, единая аналитика, дёшево | нужна продуманная мультитенантность, конфликт акций |
| Бот на каждую точку | максимальная независимость | разрозненные данные, дорого, разный UX |
| Один бот + кабинет франчайзи (рекомендую) | единый UX + локальная гибкость | требует более сложной архитектуры |
Для большинства сетей и франшиз подходит третий вариант: один бот в MAX (@network_brand_bot), пользователь выбирает точку, дальше работает с конкретной локацией. Управляющая компания получает сквозную базу пользователей и аналитику; франчайзи — отдельный веб-кабинет, где правит своё меню, акции, рассылки и видит свои метрики.
Модель данных
CREATE TABLE locations (
id BIGSERIAL PRIMARY KEY,
franchisee_id BIGINT REFERENCES franchisees(id),
title TEXT NOT NULL,
address TEXT NOT NULL,
geo GEOGRAPHY(POINT) NOT NULL,
phone TEXT,
work_hours JSONB, -- {"mon":"09:00-22:00",...}
timezone TEXT DEFAULT 'Europe/Moscow',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ix_locations_geo ON locations USING GIST(geo);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
title TEXT NOT NULL,
base_price NUMERIC(10,2) NOT NULL,
is_global BOOLEAN DEFAULT true -- меню сети
);
CREATE TABLE location_products ( -- локальные цены и наличие
location_id BIGINT REFERENCES locations(id),
product_id BIGINT REFERENCES products(id),
price NUMERIC(10,2),
available BOOLEAN DEFAULT true,
PRIMARY KEY (location_id, product_id)
);
CREATE TABLE user_location ( -- последний выбор пользователя
user_id BIGINT PRIMARY KEY,
location_id BIGINT REFERENCES locations(id),
chosen_at TIMESTAMPTZ DEFAULT now()
);
Глобальное меню задаётся управляющей компанией. Локальные цены и наличие переопределяются точкой через её кабинет.
Выбор точки через геолокацию
@bot.message_handler(commands=["start"])
async def on_start(msg):
kb = ReplyKeyboardMarkup([
[KeyboardButton("📍 Найти ближайшую точку", request_location=True)],
[KeyboardButton("🔍 Выбрать из списка")],
], one_time_keyboard=True, resize_keyboard=True)
await bot.send_message(msg.chat.id, "Здравствуйте! Выберите вашу точку:", reply_markup=kb)
@bot.message_handler(content_types=["location"])
async def on_location(msg):
nearby = await db.fetch_all("""
SELECT id, title, address,
ST_Distance(geo, ST_MakePoint($1, $2)::geography) AS dist_m
FROM locations
WHERE is_active
ORDER BY geo <-> ST_MakePoint($1, $2)::geography
LIMIT 5
""", msg.location.longitude, msg.location.latitude)
rows = [
[InlineKeyboardButton(
f"{loc['title']} — {round(loc['dist_m']/1000, 1)} км",
callback_data=f"loc:set:{loc['id']}",
)] for loc in nearby
]
await bot.send_message(msg.chat.id, "Ближайшие точки:", reply_markup=InlineKeyboardMarkup(rows))
Хранение геопозиций в geography(POINT) + GIST-индекс даёт суб-секундный поиск по радиусу даже на 10 000 точках.
Локальные меню и акции
После выбора точки пользователь видит меню именно своей кофейни:
async def show_menu(chat_id: int, user_id: int):
loc = await get_user_location(user_id)
items = await db.fetch_all("""
SELECT p.id, p.title, COALESCE(lp.price, p.base_price) AS price,
COALESCE(lp.available, true) AS available
FROM products p
LEFT JOIN location_products lp
ON lp.product_id = p.id AND lp.location_id = $1
WHERE COALESCE(lp.available, true) = true
ORDER BY p.title
""", loc.id)
text = f"Меню — {loc.title}\n\n" + "\n".join(
f"• {it.title} — {it.price}₽" for it in items
)
await bot.send_message(chat_id, text)
Акции — таблица promotions со scope global | location. Глобальная активна на всех точках, локальная — только на одной.
Кабинет франчайзи
Веб-приложение (на React/Next.js, защищённое JWT/SSO), куда франчайзи логинится через MAX-аккаунт (логин по deep link с токеном). Возможности:
- Редактировать локальные цены и наличие.
- Включать/выключать товары на своей точке.
- Создавать локальные акции (срок, скидка, баннер).
- Запускать рассылки только по своим клиентам с лимитом и ценой.
- Видеть метрики: GMV, средний чек, выручка по дням, top-товары.
- Получать отзывы и NPS.
Кабинет — отдельный сервис, общая БД с ботом. Авторизация:
# веб-эндпоинт логина: /auth/max?token=...
@router.get("/auth/max")
async def auth_via_max(token: str):
# token выдан ботом по команде /cabinet, действителен 5 минут
user_id = await consume_login_token(token)
if not user_id:
raise HTTPException(401)
franchisee = await db.fetch_one(
"SELECT * FROM franchisees WHERE id IN (SELECT franchisee_id FROM users WHERE id = $1)",
user_id,
)
if not franchisee:
raise HTTPException(403, "Не франчайзи")
return {"jwt": jwt_for(franchisee)}
В боте:
@bot.message_handler(commands=["cabinet"])
async def on_cabinet(msg):
if not await is_franchisee(msg.from_user.id):
return await bot.send_message(msg.chat.id, "Команда доступна только франчайзи")
token = await issue_login_token(msg.from_user.id, ttl=300)
url = f"https://cabinet.network.ru/auth/max?token={token}"
await bot.send_message(
msg.chat.id,
"Войдите в кабинет франчайзи (ссылка действует 5 минут):",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Открыть кабинет", url=url)]]),
)
Роли и права
Минимум 4 роли:
- owner (управляющая компания) — видит всё, редактирует глобальное меню, политики бота, шаблоны рассылок.
- franchisee — управляет одной или несколькими своими точками.
- manager_location — менеджер конкретной точки, ограниченные права (наличие, отзывы).
- support — отвечает в live-chat за все точки или фильтр по точке.
CREATE TABLE user_roles (
user_id BIGINT REFERENCES users(id),
role TEXT NOT NULL,
location_id BIGINT REFERENCES locations(id), -- NULL = вся сеть
PRIMARY KEY (user_id, role, location_id)
);
В коде middleware проверяет, имеет ли франчайзи права на конкретную точку:
async def require_location(user_id: int, location_id: int) -> bool:
return await db.fetch_val("""
SELECT EXISTS(
SELECT 1 FROM user_roles
WHERE user_id = $1
AND role IN ('owner', 'franchisee', 'manager_location')
AND (location_id IS NULL OR location_id = $2)
)
""", user_id, location_id)
Аналитика и отчёты
Управляющая компания хочет:
- DAU/MAU по сети;
- топ-точки по выручке через бота;
- LTV пользователя по сети;
- единая воронка: открыл бот → выбрал точку → заказал → оплатил.
Франчайзи хочет:
- те же метрики, но только по своей точке;
- сравнение с среднесетевыми (анонимно).
Дашборд в Metabase / Datalens, подключённый к read-replica Postgres. Графики фильтруются по location_id через row-level security — франчайзи не видит чужие данные:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY franchisee_orders ON orders
FOR SELECT USING (
location_id IN (
SELECT location_id FROM user_roles
WHERE user_id = current_setting('app.user_id')::bigint
)
);
Поддержка и эскалация по точке
Когда пользователь жалуется через /help, тикет должен попасть к команде поддержки именно его точки, а не в общую очередь. Маршрутизация в FSM:
async def escalate_to_operator(user_id: int, message: str):
loc = await get_user_location(user_id)
chat_id = await db.fetch_val(
"SELECT support_chat_id FROM locations WHERE id = $1", loc.id
)
chat_id = chat_id or DEFAULT_SUPPORT_CHAT
await bot.send_message(
chat_id,
f"Обращение от пользователя {user_id} ({loc.title}):\n\n{message}",
)
Управляющая компания дополнительно мониторит общий канал «жалобы по сети» — туда падают все escalations с метаданными точки.
Рассылки: ограничения для франчайзи
Без ограничений франчайзи может разослать спам на 50 000 клиентов сети. Правила:
- Франчайзи может отправлять только клиентам своих точек (по последнему выбору пользователя).
- Лимит 4 рассылки в месяц на точку.
- Модерация: текст рассылки уходит в очередь approval owner'а, прежде чем уйти.
- Биллинг: каждое сообщение — N копеек, списывается с баланса франчайзи.
async def schedule_broadcast(franchisee_id: int, location_id: int, text: str):
await assert_can_broadcast(franchisee_id, location_id)
audience = await db.fetch_all("""
SELECT user_id FROM user_location WHERE location_id = $1
""", location_id)
cost = len(audience) * BROADCAST_COST
await ensure_balance(franchisee_id, cost)
await put_broadcast_in_queue(text, audience, franchisee_id, location_id)
Onboarding нового франчайзи
Шаги:
- Управляющая компания создаёт
franchiseeиlocationв админ-панели. - Высылает ссылку на кабинет с одноразовым токеном настройки.
- Франчайзи логинится через MAX, заполняет адрес, расписание, телефон, выбирает локальные цены и товары из глобального каталога.
- Прогон чеклиста: загружено фото точки, заполнено меню, привязан Telegram/MAX-чат для эскалаций.
- Точка становится
is_active=true, появляется в боте.
Common pitfalls
- Ленивый выбор точки — пользователь однажды выбрал и думает, что бот «знает» его навсегда. Раз в 30 дней или при подозрительной геолокации спрашивайте заново.
- Глобальные акции, которые ломают экономику франчайзи — обязательно согласование/возможность opt-out.
- Один токен бота на сеть — если ключ утечёт, упадут все. Регулярная ротация.
- Франчайзи видит контакты клиентов сети — нарушение 152-ФЗ. Доступ к user_id и контактам — только в рамках своей точки и через RLS.
- Нет аудит-лога действий франчайзи — кто и когда поднял цену на 30%? Без журнала разбирательств не будет.
Итого
Бот для франшизы и сети заведений в MAX строится по схеме «один бот + мультитенантная БД + кабинет франчайзи». Геопоиск точки через PostGIS GIST-индекс, локальные меню и акции через таблицу-overlay поверх глобального каталога, роли (owner / franchisee / manager / support) с RLS на уровне Postgres, отдельный веб-кабинет на JWT с логином через MAX deep link, рассылки франчайзи только своим клиентам с модерацией и биллингом, эскалации в чат поддержки конкретной точки. Управляющая компания получает сквозную аналитику и единый стандарт UX, франчайзи — независимость в управлении своей точкой. Срок реализации MVP — 4–6 недель, полнофункциональной версии — 10–14 недель.
Частые вопросы
Делать один бот на всю сеть или по одному на точку?
Для 90% сетей оптимален один бот в MAX с мультитенантной архитектурой и кабинетом франчайзи. Это даёт единый бренд, узнаваемый handle, сквозную аналитику для управляющей компании и удобство для пользователей, которые часто переезжают между точками. Отдельные боты на каждую точку оправданы только в редких случаях: точки разных юрлиц без единой управляющей структуры, разные ценовые сегменты бренда, разные регионы с независимым маркетингом.
Как пользователь выбирает свою точку в боте?
Два пути: автоматический по геолокации (запрос location, поиск ближайших через PostGIS ST_Distance с GIST-индексом, показ топ-5 в inline-кнопках) и ручной (фильтр по городу/району/станции метро). Запоминайте выбор в таблице user_location и используйте при формировании меню, цен, наличия. Раз в 30 дней или при резкой смене геолокации (новая координата отличается от запомненной точки на > 50 км) спрашивайте подтверждение — клиенты переезжают и забывают переключить.
Как разделить права франчайзи и управляющей компании?
Реализуйте 4 роли: owner (всё, все точки), franchisee (свои точки), manager_location (одна точка, ограниченные действия), support. Доступ выдавайте через таблицу user_roles с привязкой к location_id (NULL = вся сеть). На уровне Postgres включайте Row Level Security: franchisee физически не может прочитать orders/users чужих точек, даже если в коде ошибка с фильтром. Аудит-лог всех изменений (кто и когда поднял цену, добавил акцию, запустил рассылку) — обязателен.
Как организовать локальные меню и акции?
Глобальный каталог products задаёт управляющая компания. Локальные переопределения хранятся в таблице location_products с ценой и available — JOIN при выдаче меню берёт LOCAL значение или fallback на BASE. Акции — таблица promotions со scope global|location, period start/end, и условиями применения. Франчайзи может включить/отключить участие в глобальной акции, если это разрешено правилами франшизы. Для согласования сложных кейсов — workflow approval owner'а с уведомлением через бота.
Как защитить рассылки франчайзи от спама и нарушений 152-ФЗ?
Франчайзи может отправлять только пользователям, выбравшим его точку (фильтр по user_location). Лимит 4 рассылки в месяц на точку, модерация текста owner'ом перед отправкой, биллинг по копейкам за сообщение со списанием с баланса франчайзи. Согласие на рассылки берётся при первом контакте с ботом (отдельная команда /subscribe или галочка). У пользователя должен быть простой /unsubscribe и /unsubscribe_location для отписки от конкретной точки. Все события (отправка, доставка, отписка) логируются — это требование 152-ФЗ.
Как сделать кабинет франчайзи с авторизацией через MAX?
Отдельный веб-сервис на React/Next.js + бэкенд с JWT. Логин через бот: пользователь пишет /cabinet, бот выдаёт одноразовую ссылку с токеном на 5 минут вида https://cabinet.brand.ru/auth/max?token=.... Веб обменивает токен на JWT, проверяет роль franchisee и привязанные точки. JWT короткий (1–4 часа), refresh через тот же flow или OAuth-style refresh token. Дополнительно — 2FA для критичных действий (изменение цен > 20%, рассылка > 1000 пользователей). Кабинет работает по HTTPS, никогда не передавайте токен в URL после первой обмена.
Сколько стоит и сколько занимает разработка?
MVP с одним ботом, выбором точки, единым меню, эскалациями по точке — 4–6 недель и 700–1500 тыс. ₽. Полнофункциональная версия с кабинетом франчайзи, локальными меню/акциями, рассылками с биллингом, ролевой моделью, аналитикой — 10–14 недель и 2–4 млн ₽. Поддержка инфраструктуры — 30–80 тыс. ₽/мес в зависимости от размера сети (10 точек vs 200). Окупаемость для сети из 30 точек обычно 3–6 месяцев за счёт автоматизации заказов и снижения нагрузки на колл-центр.