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. Бизнес-правила и валидации
Таблица правил смены статусов
| Текущий статус | Допустимые переходы | Недопустимые переходы |
|---|---|---|
new | contacted | Все остальные |
contacted | enrolled, no_show | new, финальные напрямую |
enrolled | attended, no_show | new, contacted, финальные напрямую |
attended | purchased, not_purchased | Все кроме финальных |
no_show | contacted, not_purchased | new, enrolled, attended, purchased |
purchased | — (финальный) | Все |
not_purchased | — (финальный) | Все |
Таблица бизнес-правил
| Правило | Описание | Обработка нарушения |
|---|---|---|
| Статус только вперёд (кроме no_show→contacted) | Нельзя откатить статус произвольно | 400 INVALID_STATUS_TRANSITION |
| Финальный статус неизменяем | purchased и not_purchased — терминальные | Кнопка не отображается, 400 если через API |
| Смена статуса только своих лидов | Продавец не может менять чужие лиды | 403 FORBIDDEN |
| Лид привязан к айтему | Нельзя создать лид без item_id | 400 ITEM_NOT_FOUND |
| Только авторизованный продавец | Staff тоже может менять статусы | 401/403 |
| Admin только read-only | Admin не меняет статусы | 403 FORBIDDEN |
| Бейдж новых лидов в навигации | Считается по статусу new для данного seller_id | Обновляется real-time после изменения статуса |
| Гость-лид (buyer_account_id = null) | Лид создан без регистрации покупателя — отображается нормально | — |
Таблица валидаций API
| Поле | Правило | Ошибка |
|---|---|---|
status | Одно из: new, contacted, enrolled, attended, no_show, purchased, not_purchased | 422 INVALID_ENUM_VALUE |
lead_id | Существующий UUID, принадлежит seller_id из токена | 404 LEAD_NOT_FOUND / 403 FORBIDDEN |
| Переход статуса | Соответствует матрице переходов | 400 INVALID_STATUS_TRANSITION |
Pagination: page | Integer ≥ 1, default: 1 | 422 |
Pagination: limit | Integer 1–100, default: 20 | 422 |
Filter: status | Один из допустимых статусов или all | 422 |
Filter: item_id | UUID, принадлежит данному seller_id | 403 |
5. Модель данных
Lead — основная сущность этого спека. Seller и Item определены в Spec 01 и Spec 02 соответственно.
Lead
| Атрибут | Тип | Описание |
|---|---|---|
| lead_id | UUID | PK |
| item_id | UUID FK | → Item (Spec 02), курс на который оставлена заявка |
| seller_id | UUID FK | → Seller (Spec 01), денормализованный для быстрых запросов |
| buyer_account_id | UUID FK? | → Account, nullable (гостевые заявки) |
| lead_name | string | Имя покупателя как он ввёл |
| lead_phone | string | Телефон в формате +998XXXXXXXXX |
| lead_email | string? | Email, опциональный |
| lead_comment | string? | Комментарий покупателя, max 500 символов |
| lead_type | LeadType | trial / buy |
| lead_status | LeadStatus | new / contacted / enrolled / attended / no_show / purchased / not_purchased |
| special_offer_id | UUID FK? | → SpecialOffer (Spec 07), если заявка через акцию |
| created_at | DateTime | Время создания заявки |
| updated_at | DateTime | Время последнего обновления (для статуса) |
LeadStatusHistory (для аудита — MVP опционально, v1.0 обязательно)
| Атрибут | Тип | Описание |
|---|---|---|
| id | UUID | PK |
| lead_id | UUID FK | → Lead |
| from_status | LeadStatus? | Предыдущий статус (null для первого) |
| to_status | LeadStatus | Новый статус |
| changed_by_account_id | UUID FK | Кто изменил (seller owner или staff) |
| changed_at | DateTime | Время изменения |
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.0 | UC-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 к счёту |