Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-09-lead-management.md

MVP Spec 09 — Lead Management (Seller Side)

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 09 — Lead Management (Seller Side)

Паспорт документа

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

Version: MVP · Priority: P0 · Phase: A (Supply) Status: Draft v1


1. Контекст и цель

Лиды — это главный продукт, который получают продавцы от платформы. Покупатель оставляет заявку на курс (trial или buy), заявка поступает в кабинет продавца и превращается в сделку.

Цель модуля: дать продавцу полный контроль над входящими лидами — видеть их в реальном времени, управлять статусами по согласованной воронке, фильтровать и детализировать каждую заявку. Это ключевой инструмент для превращения лидов в выручку.

Монетизация (CPL): продавец платит $30 за каждый доставленный лид (статус new). Учёт выставляется еженедельно/ежемесячно через reconciliation (Spec 15). Данный спек описывает только управление лидами, не биллинг.

Что не входит в этот модуль:

  • Форма оставления заявки покупателем → Spec 06 (Buyer Flow)
  • Создание и управление курсами/айтемами → Spec 02
  • Уведомления при поступлении лида → Spec 10
  • Биллинг и reconciliation продавца → Spec 15
  • Публичная страница курса → Spec 08

2. Роли пользователей

РольДействие в этом модуле
Seller (Owner)Просматривает входящие лиды, меняет статусы, фильтрует, смотрит детали
Seller StaffТе же права что у Seller — просмотр и управление лидами своей организации (Spec 03)
AdminВидит все лиды в системе; может просматривать детали, не может менять статусы от имени продавца
Гость / BuyerНе имеет доступа к этому разделу

3. Use Cases


UC-01: Продавец открывает список лидов

Актор: Seller (Owner) или Seller Staff Предусловие: Пользователь авторизован как продавец, находится в личном кабинете Триггер: Продавец хочет посмотреть входящие заявки

Полный поток:

[Точка входа]
→ Продавец авторизован, находится на /seller (дашборд)
→ Видит в левой навигации раздел "Заявки" (или "Лиды") с бейджем числа новых (синий кружок)
→ Нажимает "Заявки"
→ Открывается /seller/leads

───────────────────────────────────────────────────────
ШАГ 1 — Загрузка страницы списка лидов
───────────────────────────────────────────────────────
→ Страница загружает лиды: GET /api/v1/seller/leads
→ Пока грузится: скелетон-таблица (3–5 строк серых placeholder)
→ После загрузки: таблица с лидами, отсортированными по created_at DESC

───────────────────────────────────────────────────────
ШАГ 2 — Состояние таблицы при наличии лидов
───────────────────────────────────────────────────────
→ Таблица/карточный вид с колонками:
    [ ] | Имя покупателя | Телефон | Курс (item_name) | Тип | Статус | Дата | Комментарий | Действия

→ Каждая строка:
    - Имя: lead_name
    - Телефон: lead_phone (показывается полностью, кликабельный tel: ссылкой на мобайле)
    - Курс: item_name + ссылка на /seller/items/:item_id
    - Тип: бейдж "Пробное" (если trial) или "Запись" (если buy)
    - Статус: цветной бейдж (см. ниже)
    - Дата: created_at в формате "25 мар 2026, 14:35"
    - Комментарий: первые 60 символов + "..." если длиннее
    - Действия: кнопка быстрого действия + иконка раскрытия деталей

→ Бейджи статусов:
    new           = синий фон      "Новая"
    contacted     = жёлтый фон     "Контакт"
    enrolled      = зелёный фон    "Записан"
    attended      = тёмно-зелёный  "Посетил"
    no_show       = красный фон    "Не пришёл"
    purchased     = жирный зелёный "Оплатил"
    not_purchased = серый фон      "Не купил"

→ Кнопка быстрого действия для статуса new:
    [Контакт] — нажатие немедленно меняет статус на contacted (UC-02)

→ Для остальных статусов: кнопка-дропдаун [▾ Статус]
    → Выпадают только допустимые следующие переходы (UC-03)

→ Нажатие на строку (кроме кнопки действия) → разворачивает детальный вид (UC-05)

───────────────────────────────────────────────────────
ШАГ 3 — Пустое состояние (нет лидов)
───────────────────────────────────────────────────────
→ Иконка: конверт с вопросительным знаком
→ Заголовок: "Пока нет заявок"
→ Текст: "Когда покупатели оставят заявки на ваши курсы, они появятся здесь."
→ Кнопка: "Посмотреть мои курсы" → /seller/items

UC-01 — Альтернативные потоки и обработка ошибок

1a. Сеть недоступна или сервер вернул 5xx при загрузке списка:

UI-реакция:
→ Вместо скелетона: иконка "нет сети" + текст "Не удалось загрузить заявки."
→ Кнопка "Попробовать снова" — повторяет запрос
→ Если ошибка повторяется: "Проблема на нашей стороне. Попробуйте через несколько минут или напишите в поддержку."
→ Ссылка "Написать в поддержку" → Telegram/email поддержки

1b. Продавец не имеет ни одного активного айтема:

→ Лиды могут появиться только на опубликованные айтемы
→ Если у продавца нет айтемов: отображается баннер под заголовком страницы
→ Баннер (жёлтый): "У вас ещё нет опубликованных курсов. Добавьте курс чтобы начать получать заявки."
→ Кнопка: "Добавить курс" → /seller/items/new
→ Список лидов при этом пуст, показывается пустое состояние

1c. Токен истёк при открытии страницы:

→ API возвращает 401
→ Фронтенд пытается обновить токен через refresh endpoint
→ Если refresh успешен: прозрачно повторяет запрос и показывает страницу
→ Если refresh провалился: редирект на /login с параметром ?redirect=/seller/leads
→ После логина: возврат на /seller/leads

UC-02: Продавец меняет статус лида (new → contacted)

Актор: Seller (Owner) или Seller Staff Предусловие: В списке есть лид со статусом new Триггер: Продавец позвонил покупателю и хочет отметить контакт

Полный поток:

[Точка входа]
→ Продавец находится на /seller/leads
→ Видит строку лида со статусом "Новая" (синий бейдж)
→ Справа в строке: кнопка [Контакт]

───────────────────────────────────────────────────────
ШАГ 1 — Быстрый контакт
───────────────────────────────────────────────────────
→ Продавец нажимает кнопку [Контакт]
→ Кнопка переходит в состояние loading (spinner, disabled)
→ Отправляется: PATCH /api/v1/seller/leads/:lead_id/status { status: 'contacted' }
→ Статус обновляется в БД

───────────────────────────────────────────────────────
ШАГ 2 — Отображение результата
───────────────────────────────────────────────────────
→ Бейдж в строке меняется: "Новая" (синий) → "Контакт" (жёлтый) — анимация смены
→ Кнопка [Контакт] заменяется на дропдаун [▾ Статус] с дальнейшими переходами
→ Toast (зелёный): "Статус изменён на «Контакт»"
→ Бейдж "новых" в навигации уменьшается на 1

UC-02 — Альтернативные потоки

2a. Сеть пропала во время изменения статуса:

UI-реакция:
→ Кнопка разблокируется после таймаута 10 секунд
→ Toast (красный): "Не удалось изменить статус. Проверьте соединение и попробуйте снова."
→ Статус строки остаётся прежним (оптимистичный UI не применяем для статусов — только после подтверждения сервера)

2b. Сервер вернул 409 (статус уже изменён другим сотрудником):

UI-реакция:
→ Toast (жёлтый): "Статус уже был изменён другим сотрудником. Обновляем данные..."
→ Строка лида автоматически обновляется до актуального статуса

2c. Лид не найден (404):

UI-реакция:
→ Toast (красный): "Заявка не найдена. Возможно, она была удалена."
→ Строка исчезает из списка

UC-03: Продавец проводит лид через полный статусный конвейер

Актор: Seller (Owner) или Seller Staff Предусловие: Лид существует в любом промежуточном статусе Триггер: Продавец хочет зафиксировать прогресс по заявке

Полный поток:

[Точка входа]
→ Продавец находится на /seller/leads
→ Нажимает дропдаун [▾ Статус] на строке лида

───────────────────────────────────────────────────────
Статусная воронка и допустимые переходы
───────────────────────────────────────────────────────

new
  → contacted (только вперёд, кнопка [Контакт])

contacted
  → enrolled    "Записан на занятие"
  → no_show     "Не пришёл" (если договорились, но не пришёл без записи)

enrolled
  → attended    "Пришёл на занятие"
  → no_show     "Не пришёл на занятие"

attended
  → purchased      "Оплатил / Купил курс"
  → not_purchased  "Не купил после занятия"

no_show
  → contacted   "Перезвонили, договорились снова"
  → not_purchased  "Не дозвонились, закрываем"

purchased     — финальный статус, изменение не доступно
not_purchased — финальный статус, изменение не доступно

───────────────────────────────────────────────────────
ШАГ 1 — Открытие дропдауна
───────────────────────────────────────────────────────
→ Нажатие на [▾ Статус] открывает выпадающий список
→ В списке только допустимые переходы (недопустимые не показываются)
→ Каждый пункт: цветной кружок + название статуса

───────────────────────────────────────────────────────
ШАГ 2 — Выбор нового статуса
───────────────────────────────────────────────────────
→ Продавец выбирает статус
→ Для переходов в финальные статусы (purchased / not_purchased) — диалог подтверждения:
    "Вы отмечаете заявку как «{статус}». Это завершит работу с ней."
    [Отмена] [Подтвердить]
→ Для остальных переходов — немедленное применение без диалога

───────────────────────────────────────────────────────
ШАГ 3 — Применение статуса
───────────────────────────────────────────────────────
→ PATCH /api/v1/seller/leads/:lead_id/status { status: 'новый_статус' }
→ Бейдж строки обновляется
→ Toast: "Статус изменён на «{название}»"
→ Если финальный статус: строка визуально приглушается (opacity 0.7) для отличия от активных

UC-03 — Альтернативные потоки

3a. Продавец пытается изменить финальный статус:

UI-реакция:
→ Дропдаун [▾ Статус] не отображается для финальных статусов purchased / not_purchased
→ Вместо него: статичный бейдж без интерактивности
→ При наведении tooltip: "Это завершённая заявка. Статус нельзя изменить."

3b. Сервер вернул 400 INVALID_STATUS_TRANSITION:

UI-реакция:
→ Toast (красный): "Недопустимый переход статуса. Обновите страницу."
→ Строка обновляется до актуального статуса из ответа сервера

UC-04: Продавец фильтрует лиды по статусу

Актор: Seller (Owner) или Seller Staff Предусловие: Продавец находится на /seller/leads Триггер: Продавец хочет сфокусироваться на конкретном этапе воронки

Полный поток:

[Точка входа]
→ Продавец на /seller/leads, видит список всех лидов
→ Над таблицей: панель фильтров

───────────────────────────────────────────────────────
ШАГ 1 — Панель фильтров
───────────────────────────────────────────────────────
→ Фильтры отображаются в виде горизонтальных таб-чипов:
    [Все] [Новые (N)] [Контакт (N)] [Записан (N)] [Посетил (N)]
          [Не пришёл (N)] [Оплатил (N)] [Не купил (N)]
→ В скобках — количество лидов в каждом статусе
→ По умолчанию активен чип "Все"

───────────────────────────────────────────────────────
ШАГ 2 — Выбор фильтра
───────────────────────────────────────────────────────
→ Продавец нажимает чип "Новые"
→ Чип подсвечивается (активный)
→ URL обновляется: /seller/leads?status=new
→ Таблица перезагружается: GET /api/v1/seller/leads?status=new
→ Показываются только лиды с status = new

───────────────────────────────────────────────────────
ШАГ 3 — Комбинированная фильтрация
───────────────────────────────────────────────────────
→ Дополнительные фильтры (раскрываемая панель "Ещё фильтры"):
    - По курсу: дропдаун со списком айтемов продавца
    - По типу: Все / Пробное / Запись
    - По дате: date-picker "от" / "до"
→ Фильтры применяются немедленно при изменении
→ Активные фильтры показываются как теги над таблицей с кнопкой [×] для сброса каждого

───────────────────────────────────────────────────────
ШАГ 4 — Пустой результат фильтра
───────────────────────────────────────────────────────
→ Если фильтр не находит лидов:
    Иконка + текст: "По выбранным фильтрам заявок нет."
    Кнопка: "Сбросить фильтры"

UC-04 — Альтернативные потоки

4a. Сеть пропала при смене фильтра:

UI-реакция:
→ Предыдущий список остаётся на экране (не очищается)
→ Toast (красный): "Не удалось применить фильтр. Проверьте соединение."
→ Чип возвращается в предыдущее выбранное состояние

UC-05: Продавец просматривает детали лида

Актор: Seller (Owner) или Seller Staff Предусловие: Продавец на /seller/leads Триггер: Продавец хочет видеть полные контактные данные и детали заявки

Полный поток:

[Точка входа]
→ Продавец на /seller/leads, видит строку лида
→ Нажимает на строку (не на кнопку действия)

───────────────────────────────────────────────────────
ШАГ 1 — Разворачивание деталей
───────────────────────────────────────────────────────
→ Строка раскрывается вниз (accordion) ИЛИ открывается сайд-панель справа
→ Блок деталей содержит:

    ┌─────────────────────────────────────────────────────┐
    │  ЗАЯВКА #lead_id (сокращённый)        [×] Закрыть  │
    │─────────────────────────────────────────────────────│
    │  Покупатель:  {lead_name}                           │
    │  Телефон:     {lead_phone}   [📋 Скопировать]       │
    │  Email:       {lead_email или "—"}                  │
    │                                                     │
    │  Курс:        {item_name}    [→ Перейти к курсу]    │
    │  Тип заявки:  Пробное занятие / Запись              │
    │  Акция:       {special_offer_name или "—"}          │
    │  Дата заявки: 25 марта 2026, 14:35                  │
    │                                                     │
    │  Комментарий: {lead_comment или "—"}                │
    │                                                     │
    │  Текущий статус: [Контакт ▾]  (меняется здесь тоже)│
    └─────────────────────────────────────────────────────┘

───────────────────────────────────────────────────────
ШАГ 2 — Копирование телефона
───────────────────────────────────────────────────────
→ Продавец нажимает [📋 Скопировать] рядом с телефоном
→ Номер копируется в буфер обмена
→ Иконка меняется на ✓ на 2 секунды
→ Tooltip: "Скопировано!"

───────────────────────────────────────────────────────
ШАГ 3 — Изменение статуса из детального вида
───────────────────────────────────────────────────────
→ Работает идентично UC-02 и UC-03
→ После смены статуса: бейдж в деталях и в строке таблицы обновляются синхронно

UC-05 — Альтернативные потоки

5a. Сеть пропала при открытии деталей (если детали загружаются отдельным запросом):

UI-реакция:
→ Внутри блока деталей: "Не удалось загрузить данные заявки."
→ Кнопка "Попробовать снова"

5b. Лид удалён/не найден (маловероятно при правильной архитектуре):

UI-реакция:
→ Toast: "Заявка не найдена."
→ Строка удаляется из списка

UC-06: Экспорт лидов

Актор: Seller (Owner) Статус: TBD — запланировано в v1.0

Функциональность экспорта лидов в CSV/XLSX не входит в MVP.
Упоминается здесь как зарезервированная точка расширения.

Планируемое поведение (v1.0):
→ Кнопка [Экспорт] над таблицей
→ Экспортирует текущий отфильтрованный список
→ Поля: Имя, Телефон, Email, Курс, Тип, Статус, Дата, Комментарий
→ Форматы: CSV (UTF-8 with BOM для Excel) и XLSX
→ Лимит: не более 10 000 строк за раз

UC-07: Администратор просматривает все лиды в системе

Актор: Admin Предусловие: Admin авторизован на /admin Триггер: Admin хочет проверить лиды для модерации, аналитики или поддержки

Полный поток:

[Точка входа]
→ Admin авторизован, открывает /admin/leads

───────────────────────────────────────────────────────
ШАГ 1 — Таблица всех лидов
───────────────────────────────────────────────────────
→ GET /api/admin/leads
→ Таблица с дополнительными колонками:
    Покупатель | Телефон | Продавец (seller_name) | Курс | Тип | Статус | Дата | Акция

→ Поиск по телефону и имени покупателя
→ Фильтры: по статусу, по продавцу, по дате, по типу

───────────────────────────────────────────────────────
ШАГ 2 — Детальный просмотр
───────────────────────────────────────────────────────
→ Клик на строку → открывается детальный вид (идентичен UC-05)
→ Дополнительно для Admin: отображается seller_id и buyer_account_id

───────────────────────────────────────────────────────
Ограничения Admin
───────────────────────────────────────────────────────
→ Admin НЕ может менять статус лида от имени продавца
→ Admin видит данные только в режиме read-only для лидов
→ Если нужно — может обратиться к продавцу через другие каналы

UC-07 — Альтернативные потоки

7a. Admin пытается изменить статус:

→ Кнопки изменения статуса не отображаются в Admin-виде
→ Попытка через прямой API: 403 { error: 'FORBIDDEN', message: 'Администратор не может изменять статусы лидов.' }

4. Бизнес-правила и валидации

Таблица правил смены статусов

Текущий статусДопустимые переходыНедопустимые переходы
newcontactedВсе остальные
contactedenrolled, no_shownew, финальные напрямую
enrolledattended, no_shownew, contacted, финальные напрямую
attendedpurchased, not_purchasedВсе кроме финальных
no_showcontacted, not_purchasednew, enrolled, attended, purchased
purchased— (финальный)Все
not_purchased— (финальный)Все

Таблица бизнес-правил

ПравилоОписаниеОбработка нарушения
Статус только вперёд (кроме no_show→contacted)Нельзя откатить статус произвольно400 INVALID_STATUS_TRANSITION
Финальный статус неизменяемpurchased и not_purchased — терминальныеКнопка не отображается, 400 если через API
Смена статуса только своих лидовПродавец не может менять чужие лиды403 FORBIDDEN
Лид привязан к айтемуНельзя создать лид без item_id400 ITEM_NOT_FOUND
Только авторизованный продавецStaff тоже может менять статусы401/403
Admin только read-onlyAdmin не меняет статусы403 FORBIDDEN
Бейдж новых лидов в навигацииСчитается по статусу new для данного seller_idОбновляется real-time после изменения статуса
Гость-лид (buyer_account_id = null)Лид создан без регистрации покупателя — отображается нормально

Таблица валидаций API

ПолеПравилоОшибка
statusОдно из: new, contacted, enrolled, attended, no_show, purchased, not_purchased422 INVALID_ENUM_VALUE
lead_idСуществующий UUID, принадлежит seller_id из токена404 LEAD_NOT_FOUND / 403 FORBIDDEN
Переход статусаСоответствует матрице переходов400 INVALID_STATUS_TRANSITION
Pagination: pageInteger ≥ 1, default: 1422
Pagination: limitInteger 1–100, default: 20422
Filter: statusОдин из допустимых статусов или all422
Filter: item_idUUID, принадлежит данному seller_id403

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

Lead — основная сущность этого спека. Seller и Item определены в Spec 01 и Spec 02 соответственно.

Lead

АтрибутТипОписание
lead_idUUIDPK
item_idUUID FK→ Item (Spec 02), курс на который оставлена заявка
seller_idUUID FK→ Seller (Spec 01), денормализованный для быстрых запросов
buyer_account_idUUID FK?→ Account, nullable (гостевые заявки)
lead_namestringИмя покупателя как он ввёл
lead_phonestringТелефон в формате +998XXXXXXXXX
lead_emailstring?Email, опциональный
lead_commentstring?Комментарий покупателя, max 500 символов
lead_typeLeadTypetrial / buy
lead_statusLeadStatusnew / contacted / enrolled / attended / no_show / purchased / not_purchased
special_offer_idUUID FK?→ SpecialOffer (Spec 07), если заявка через акцию
created_atDateTimeВремя создания заявки
updated_atDateTimeВремя последнего обновления (для статуса)

LeadStatusHistory (для аудита — MVP опционально, v1.0 обязательно)

АтрибутТипОписание
idUUIDPK
lead_idUUID FK→ Lead
from_statusLeadStatus?Предыдущий статус (null для первого)
to_statusLeadStatusНовый статус
changed_by_account_idUUID FKКто изменил (seller owner или staff)
changed_atDateTimeВремя изменения

6. Технические контракты

6.1 Prisma Schema

enum LeadType {
  trial
  buy
}

enum LeadStatus {
  new
  contacted
  enrolled
  attended
  no_show
  purchased
  not_purchased
}

model Lead {
  lead_id           String      @id @default(uuid())
  item_id           String
  seller_id         String
  buyer_account_id  String?
  lead_name         String      @db.VarChar(100)
  lead_phone        String
  lead_email        String?
  lead_comment      String?     @db.VarChar(500)
  lead_type         LeadType
  lead_status       LeadStatus  @default(new)
  special_offer_id  String?
  created_at        DateTime    @default(now())
  updated_at        DateTime    @updatedAt

  item              Item        @relation(fields: [item_id], references: [item_id])
  seller            Seller      @relation(fields: [seller_id], references: [seller_id])
  buyer_account     Account?    @relation(fields: [buyer_account_id], references: [account_id])
  status_history    LeadStatusHistory[]

  @@index([seller_id, lead_status])
  @@index([seller_id, created_at])
  @@index([item_id])
}

model LeadStatusHistory {
  id                      String      @id @default(uuid())
  lead_id                 String
  from_status             LeadStatus?
  to_status               LeadStatus
  changed_by_account_id   String
  changed_at              DateTime    @default(now())

  lead Lead @relation(fields: [lead_id], references: [lead_id])

  @@index([lead_id])
}

6.2 TypeScript DTO

// ─── Запросы ──────────────────────────────────────────────────────────────

export class UpdateLeadStatusDto {
  @IsEnum(LeadStatus, { message: 'Недопустимый статус заявки' })
  status: LeadStatus
}

export class LeadListQueryDto {
  @IsOptional() @IsEnum(LeadStatus)
  status?: LeadStatus

  @IsOptional() @IsUUID()
  item_id?: string

  @IsOptional() @IsEnum(LeadType)
  lead_type?: LeadType

  @IsOptional() @IsDateString()
  date_from?: string   // ISO 8601

  @IsOptional() @IsDateString()
  date_to?: string     // ISO 8601

  @IsOptional() @IsInt() @Min(1)
  @Type(() => Number)
  page?: number = 1

  @IsOptional() @IsInt() @Min(1) @Max(100)
  @Type(() => Number)
  limit?: number = 20
}

// ─── Ответы ───────────────────────────────────────────────────────────────

export interface LeadListItemDto {
  lead_id: string
  lead_name: string
  lead_phone: string
  lead_email: string | null
  lead_comment: string | null
  lead_type: LeadType
  lead_status: LeadStatus
  item_id: string
  item_name: string
  special_offer_name: string | null
  created_at: string  // ISO 8601
}

export interface LeadDetailDto extends LeadListItemDto {
  buyer_account_id: string | null
  updated_at: string
  status_history?: LeadStatusHistoryItemDto[]  // только для admin view
}

export interface LeadStatusHistoryItemDto {
  from_status: LeadStatus | null
  to_status: LeadStatus
  changed_at: string
}

export interface LeadListResponseDto {
  leads: LeadListItemDto[]
  total: number
  page: number
  limit: number
  status_counts: Record<LeadStatus | 'all', number>
}

6.3 API Endpoints

────────────────────────────────────────────────────────────────
SELLER: СПИСОК ЛИДОВ
────────────────────────────────────────────────────────────────

GET /api/v1/seller/leads
Auth: Bearer (seller | seller_staff)
Query: LeadListQueryDto
→ 200: LeadListResponseDto
→ 401: { error: 'UNAUTHORIZED' }
→ 422: { errors: [{ field: string, message: string }] }

────────────────────────────────────────────────────────────────
SELLER: ДЕТАЛИ ЛИДА
────────────────────────────────────────────────────────────────

GET /api/v1/seller/leads/:lead_id
Auth: Bearer (seller | seller_staff)
→ 200: LeadDetailDto
→ 403: { error: 'FORBIDDEN', message: 'Эта заявка принадлежит другому продавцу.' }
→ 404: { error: 'LEAD_NOT_FOUND', message: 'Заявка не найдена.' }

────────────────────────────────────────────────────────────────
SELLER: СМЕНА СТАТУСА
────────────────────────────────────────────────────────────────

PATCH /api/v1/seller/leads/:lead_id/status
Auth: Bearer (seller | seller_staff)
Body: UpdateLeadStatusDto
→ 200: LeadDetailDto  // обновлённый объект лида
→ 400: { error: 'INVALID_STATUS_TRANSITION', message: 'Переход из «{from}» в «{to}» недопустим.' }
→ 400: { error: 'LEAD_FINALIZED', message: 'Статус завершённой заявки нельзя изменить.' }
→ 403: { error: 'FORBIDDEN' }
→ 404: { error: 'LEAD_NOT_FOUND' }
→ 409: { error: 'CONCURRENT_UPDATE', message: 'Статус был изменён другим сотрудником.', current_status: LeadStatus }
→ 500: { error: 'INTERNAL_ERROR', message: 'Что-то пошло не так. Попробуйте позже.' }

────────────────────────────────────────────────────────────────
ADMIN: СПИСОК ВСЕХ ЛИДОВ
────────────────────────────────────────────────────────────────

GET /api/admin/leads
Auth: Bearer (admin)
Query: LeadListQueryDto + { seller_id?: string }
→ 200: LeadListResponseDto (с дополнительным полем seller_name в каждом элементе)
→ 401: { error: 'UNAUTHORIZED' }
→ 403: { error: 'FORBIDDEN' }

────────────────────────────────────────────────────────────────
ADMIN: ДЕТАЛИ ЛИДА
────────────────────────────────────────────────────────────────

GET /api/admin/leads/:lead_id
Auth: Bearer (admin)
→ 200: LeadDetailDto (включая status_history и buyer_account_id)
→ 404: { error: 'LEAD_NOT_FOUND' }

7. Edge Cases

СценарийПоведение
Два сотрудника одновременно меняют статус одного лидаВторой запрос получает 409 CONCURRENT_UPDATE с актуальным статусом; фронтенд обновляет строку
Лид создан до публикации айтема (гипотетически)lead_status остаётся new, item_name показывается как есть; айтем может быть draft
buyer_account_id = null (гостевой лид)Отображается нормально, поле покупателя "Гость"; вся функциональность смены статуса работает
Продавец заблокирован (account_status = blocked)403 FORBIDDEN на все /api/v1/seller/* эндпоинты; фронтенд показывает страницу блокировки
Фильтр по item_id, который не принадлежит продавцу403 FORBIDDEN; не возвращаем данные чужих айтемов
Лид на айтем, который был удалёнitem_name хранится денормализованно или JOIN с fallback "Курс удалён"; lead_status можно продолжать обновлять
Очень длинный lead_comment (> 500 символов)422 при создании лида (Spec 06); в списке показываются первые 60 символов + "..."
Продавец без лидов открывает /seller/leads с фильтром status=purchasedПустое состояние: "По выбранным фильтрам заявок нет." + кнопка "Сбросить фильтры"
Admin смотрит лиды продавца, у которого > 10 000 лидовPagination работает корректно; limit max 100 на запрос
special_offer_id указывает на удалённую акциюspecial_offer_name возвращается как null; UI показывает "—"
Продавец-STAFF пытается создать лид (не его роль)403 FORBIDDEN — создание лидов только через публичный buyer flow

8. TBD / Сознательно опущено

ТемаСтатусПримечание
Экспорт лидов (CSV/XLSX)Запланировано в v1.0UC-06 описан как заглушка
История изменений статусов (LeadStatusHistory)TBD для MVPТаблица описана, заполнение опционально в MVP; обязательно в v1.0
Bulk actions (массовая смена статусов)Вне скоупа MVPСложная UX-задача; отложено в v1.5
Комментарии продавца к лиду (заметки)Вне скоупа MVPПродавец не может оставлять свои заметки к заявке в MVP
Напоминания и задачи по лиду (CRM-фичи)Вне скоупаMVP не является CRM-системой
Real-time обновления (WebSocket/SSE)TBDВ MVP список обновляется при ручном рефреше или переходе на страницу; push-обновления — v1.0
Поиск по имени/телефону покупателяTBD для MVPЕсли таблица большая — нужен full-text search; заглушка на BД ILIKE
Аналитика по воронке (конверсия статусов)Вне скоупа MVPБудет в разделе аналитики v1.5
Уведомление покупателя при смене статусаTBDПродукт не определился: нужно ли уведомлять buyer о смене статуса → Spec 10
Разграничение прав staff vs owner на лидыУпрощено для MVPВ MVP staff = те же права что owner на лиды; детальные ACL в v1.0

9. Зависимости

МодульСвязь
Spec 01 (Seller Onboarding)Seller.seller_id — владелец лида; Seller.telegram_chat_id — для уведомлений
Spec 02 (Items)Item.item_id, Item.item_name — к какому курсу привязан лид
Spec 03 (Staff)SellerStaff.account_id — сотрудники видят и меняют лиды своей организации
Spec 06 (Buyer Flow)Создание лида покупателем → Lead.lead_status = new
Spec 07 (Special Offers)Lead.special_offer_id → SpecialOffer — какая акция привлекла покупателя
Spec 10 (Notifications)При lead_status = new → уведомление продавцу через Telegram/Email
Spec 15 (Billing / CPL)Подсчёт лидов для reconciliation; каждый лид со статусом new = $30 к счёту