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

Бот для франшизы и сети заведений в MAX: единый сценарий, разные точки

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

  • MAX
  • франшиза
  • ритейл

Сеть из 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 нового франчайзи

Шаги:

  1. Управляющая компания создаёт franchisee и location в админ-панели.
  2. Высылает ссылку на кабинет с одноразовым токеном настройки.
  3. Франчайзи логинится через MAX, заполняет адрес, расписание, телефон, выбирает локальные цены и товары из глобального каталога.
  4. Прогон чеклиста: загружено фото точки, заполнено меню, привязан Telegram/MAX-чат для эскалаций.
  5. Точка становится is_active=true, появляется в боте.

Common pitfalls

  1. Ленивый выбор точки — пользователь однажды выбрал и думает, что бот «знает» его навсегда. Раз в 30 дней или при подозрительной геолокации спрашивайте заново.
  2. Глобальные акции, которые ломают экономику франчайзи — обязательно согласование/возможность opt-out.
  3. Один токен бота на сеть — если ключ утечёт, упадут все. Регулярная ротация.
  4. Франчайзи видит контакты клиентов сети — нарушение 152-ФЗ. Доступ к user_id и контактам — только в рамках своей точки и через RLS.
  5. Нет аудит-лога действий франчайзи — кто и когда поднял цену на 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 месяцев за счёт автоматизации заказов и снижения нагрузки на колл-центр.