MVP Spec 02 — Item / Course Management
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
MVP Spec 02 — Item / Course Management
Паспорт документа
- Статус документа: 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. Контекст и цель
Item — центральная сущность платформы. Это любое образовательное предложение: курс, секция, занятие с репетитором. Всё, что продавец предлагает, и всё, что байер ищет — это айтем.
Цель модуля: дать продавцу инструмент для создания полноценных, подробных карточек своих курсов и услуг. Качество карточки напрямую влияет на доверие байера и конверсию в лид.
Ключевые задачи модуля:
- Создание, редактирование, удаление айтемов
- Полная матрица цен (несколько вариантов оплаты)
- Спецпредложения (скидки, акции)
- Медиа (фото и видео курса)
- Привязка преподавателей/перформеров
- Статусная модель + модерация (исправление критичного бага с видимостью)
- Управление видимостью в каталоге
Что не входит в этот модуль:
- Создание преподавателей/сотрудников → Spec 03
- Административная модерация (апрув/отклонение) → Spec 04
- Публичная карточка айтема на фронте
/item/[slug]→ Spec 06 - Каталог и поиск → Spec 05
2. Роли пользователей
| Роль | Действия |
|---|---|
| Seller Owner | Полный CRUD всех айтемов своей организации |
| Seller Admin CRM | Полный CRUD всех айтемов (как Owner, кроме настроек орг) |
| Seller Manager | Создание, редактирование, управление видимостью — без удаления |
| Seller Teacher | Только просмотр айтемов в которых участвует |
| Admin (Верификатор) | Просмотр и модерация (Spec 04) |
3. Use Cases
UC-01: Создание нового айтема (Happy Path)
Актор: Seller (Owner / Admin CRM / Manager) Предусловие: Продавец авторизован, профиль организации заполнен Триггер: Продавец нажимает "Создать курс" в личном кабинете
Полный поток:
[Точка входа]
→ Продавец находится в /seller/items
→ Нажимает кнопку "+ Создать курс"
→ Открывается /seller/items/new — многошаговая форма
─────────────────────────────────────────────────────────
ШАГ 1 — Основная информация
─────────────────────────────────────────────────────────
→ Поля:
- Название курса/услуги * (до 200 символов)
- Предмет/направление * (один выбор из дерева категорий, соответствует subject_registry)
- Краткое описание * (до 300 символов) — будет показано в карточке каталога
- Полное описание * (до 5000 символов) — подробное описание на странице айтема
- Чему научится ученик * (до 2000 символов) — список навыков и результатов
→ Нажимает "Далее"
─────────────────────────────────────────────────────────
ШАГ 2 — Параметры курса
─────────────────────────────────────────────────────────
→ Поля:
- Целевая аудитория: возраст от/до (числа) ИЛИ класс от/до (числа)
Переключатель "По возрасту / По классу"
- Тип занятий * (radio): Группа / Мини-группа / Индивидуально (1:1)
- Формат * (radio): Онлайн / Офлайн / Гибрид
- Локация: (показывается если формат = Офлайн или Гибрид)
Выбор из адресов продавца (dropdown из SellerAddress)
Или "Другой адрес" — ввод нового адреса через NominatimAutocomplete
- Язык обучения * (один выбор): Русский / Узбекский (латиница) /
Узбекский (кириллица) / Английский / Казахский / Таджикский / Любой
- Время суток (мультивыбор): Утро (6:00–12:00) / День (12:00–17:00) /
Вечер (17:00–22:00)
- Длительность одного занятия *: число + единица (мин / часов)
- Продолжительность курса (опционально): число + единица (недель / месяцев)
→ Нажимает "Далее"
─────────────────────────────────────────────────────────
ШАГ 3 — Цены
─────────────────────────────────────────────────────────
→ Матрица цен: продавец добавляет варианты оплаты
Каждый вариант содержит:
- Тип оплаты *: За занятие / За месяц / За абонемент / За пакет занятий
- Количество (для пакета): сколько занятий в пакете
- Цена * (сум UZS или $)
- Валюта: UZS / USD
- Описание варианта (необязательно, напр. "8 занятий в месяц")
Минимум 1 вариант обязательно.
Максимум 8 вариантов.
→ Нажимает "Далее"
─────────────────────────────────────────────────────────
ШАГ 4 — Фото и видео
─────────────────────────────────────────────────────────
→ Загрузка обложки курса (cover image) — обязательно:
JPG/PNG/WebP, max 10 МБ, min 800×600px
Рекомендуемое соотношение: 4:3 или 16:9
→ Дополнительные фото (галерея) — необязательно:
До 10 фотографий, те же требования
→ Видео (необязательно):
Ссылка на YouTube или Vimeo (не загрузка, только embed URL)
→ Нажимает "Далее"
─────────────────────────────────────────────────────────
ШАГ 5 — Преподаватели
─────────────────────────────────────────────────────────
→ Список сотрудников с ролью Teacher/Performer из текущей организации
→ Продавец выбирает тех, кто ведёт этот курс (мультивыбор)
→ Можно добавить без преподавателей (необязательно на MVP)
→ Нажимает "Далее"
─────────────────────────────────────────────────────────
ШАГ 6 — Предпросмотр и публикация
─────────────────────────────────────────────────────────
→ Показываем превью карточки как она будет выглядеть в каталоге
→ Показываем превью полной страницы айтема
→ Два варианта действия:
[Сохранить как черновик] — статус: draft, не видно в каталоге
[Отправить на модерацию] — статус: pending, уходит к админу на проверку
→ После выбора: редирект на /seller/items
→ Toast (зелёный): "Курс создан!" (черновик) или
"Курс отправлен на проверку. Мы уведомим вас о результате." (pending)
Индикатор прогресса: степпер из 6 шагов. Пользователь может вернуться на предыдущие шаги.
Автосохранение: каждые 30 секунд форма сохраняется как draft в БД (не только localStorage). При возврате к /seller/items/new — предлагаем продолжить незаконченный черновик.
UC-01 — Альтернативные потоки и обработка ошибок
1a. Обязательное поле пустое при переходе на следующий шаг:
UI-реакция:
→ Все незаполненные обязательные поля: красная обводка + значок ⚠
→ Под каждым: "Это поле обязательно для заполнения"
→ Страница прокручивается к первому полю с ошибкой
→ Переход к следующему шагу заблокирован до исправления
1b. Название курса превышает лимит символов:
UI-реакция:
→ Под полем — счётчик символов: "187/200"
→ При превышении: счётчик становится красным: "203/200"
→ Поле: красная обводка
→ Под полем: "Максимум 200 символов. Сократите название."
→ Кнопка "Далее" заблокирована
1c. Пользователь нажал "Назад" с заполненными полями:
Поведение:
→ Данные текущего шага сохранены (не сбрасываются)
→ Возвращаемся на предыдущий шаг с уже заполненными полями
1d. Пользователь закрыл вкладку или ушёл со страницы после заполнения Шага 1:
Поведение:
→ Данные автоматически сохранены как draft в БД
→ При возврате на /seller/items/new: баннер "У вас есть незавершённый черновик. Продолжить?"
→ [Продолжить с шага 2] [Начать заново]
→ Если "Начать заново" — старый черновик удаляется
2a. Формат = Офлайн или Гибрид, но у продавца нет добавленных адресов:
UI-реакция:
→ Поле "Локация": жёлтое предупреждение
→ Текст: "У вас нет сохранённых адресов. Добавьте адрес в профиле организации или введите адрес здесь."
→ Кнопка "Добавить адрес организации" → открывает /seller/profile в новой вкладке
→ И/или поле ввода нового адреса через NominatimAutocomplete
2b. Длительность занятия = 0 или отрицательное значение:
UI-реакция:
→ Поле: красная обводка + ⚠
→ Под полем: "Укажите корректную длительность (например: 60 минут или 1.5 часа)."
3a. Попытка добавить более 8 вариантов цены:
UI-реакция:
→ Кнопка "+ Добавить вариант" скрывается
→ Текст: "Максимум 8 вариантов оплаты."
3b. Цена = 0:
UI-реакция:
→ Поле цены: жёлтое предупреждение (не ошибка — бесплатные курсы допустимы)
→ Под полем: "Вы уверены, что курс бесплатный? Если да — оставьте 0."
→ Кнопка "Далее" остаётся доступной
4a. Загрузка обложки — файл слишком маленький (< 800×600px):
UI-реакция:
→ Поле: красная обводка
→ Под полем: "Изображение слишком маленькое. Минимальный размер: 800×600px."
→ Файл не принимается
4b. YouTube/Vimeo ссылка невалидная:
UI-реакция:
→ Поле: красная обводка + ⚠
→ Под полем: "Вставьте ссылку на YouTube или Vimeo.
Пример: https://www.youtube.com/watch?v=XXXX"
4c. Ошибка при загрузке изображения (S3/CDN недоступен):
UI-реакция:
→ Toast: "Не удалось загрузить изображение. Попробуйте ещё раз."
→ Поле сбрасывается, прогресс-бар пропадает
→ Шаг 4 можно пропустить (обложка временно пустая) и вернуться позже
6a. Технический сбой при отправке на модерацию:
UI-реакция:
→ Toast (красный): "Не удалось отправить курс на проверку. Данные сохранены как черновик. Попробуйте позже."
→ Айтем сохраняется в статусе draft
→ Кнопка "Отправить на модерацию" снова активна
UC-02: Редактирование айтема
Актор: Seller (Owner / Admin CRM / Manager)
Предусловие: Айтем существует, статус: draft, rejected, revision_required, или active
Триггер: Продавец нажимает "Редактировать" на карточке айтема
Полный поток:
→ В /seller/items нажимает "Редактировать" на нужном айтеме
→ Открывается /seller/items/[id]/edit — та же форма, предзаполненная
→ Продавец вносит изменения на любых шагах (навигация свободная)
→ Нажимает "Сохранить изменения"
Поведение при сохранении зависит от текущего статуса:
- draft → остаётся draft (без модерации)
- rejected / revision_required → статус меняется на pending (повторная модерация)
- active → статус меняется на pending (уходит на повторную модерацию)
→ Toast: "Изменения сохранены." (для draft)
или "Курс отправлен на повторную проверку." (для pending)
Альтернативные потоки:
a. Попытка редактировать айтем в статусе pending:
UI-реакция:
→ Форма открывается в режиме только для чтения
→ Баннер вверху: "Курс находится на проверке. Редактирование недоступно."
→ Опция: "Отозвать с проверки" (возвращает в draft)
→ После отзыва: айтем становится draft, можно редактировать
b. Редактирование active айтема — предупреждение:
UI-реакция:
→ При нажатии "Сохранить изменения": диалог подтверждения
→ "Курс активен и виден пользователям. После сохранения он уйдёт на повторную проверку и временно пропадёт из каталога. Продолжить?"
→ [Отмена] [Сохранить и отправить на проверку]
UC-03: Управление статусной моделью айтема (seller-side)
Состояния и переходы:
[Продавец: создал]
↓
draft
↙ ↘
[Продавец: отправил] [Продавец: удалил]
↓ ↓
pending archived
↙ ↘
[Admin: апрув] [Admin: отклонить] [Admin: на доработку]
↓ ↓ ↓
active rejected revision_required
↙ ↘ ↓ ↓
[скрыть] [↑] [Продавец: правит] → pending (повторно)
hidden active ↓
pending (повторно)
active ←→ hidden (управляется продавцом, item_isvisible)
⚠ Критичный фикс: айтемы в статусах draft, pending, rejected, revision_required, archived никогда не отображаются в публичном каталоге. Только active + item_isvisible = true.
Поведение в каталоге по статусу:
| Статус | item_isvisible | Видно в каталоге? | В /seller/items? |
|---|---|---|---|
| draft | любое | ❌ Нет | ✅ Да, с бейджем "Черновик" |
| pending | любое | ❌ Нет | ✅ Да, с бейджем "На проверке" |
| active | true | ✅ Да | ✅ Да, с бейджем "Активен" |
| active | false | ❌ Нет | ✅ Да, с бейджем "Скрыт" |
| rejected | любое | ❌ Нет | ✅ Да, с бейджем "Отклонён" + причина |
| revision_required | любое | ❌ Нет | ✅ Да, с бейджем "Нужна доработка" + комментарий |
| archived | любое | ❌ Нет | ✅ Да (опционально, или скрыт из списка) |
UC-04: Управление видимостью (скрыть / показать)
Актор: Seller (Owner / Admin CRM / Manager)
Предусловие: Айтем в статусе active
Триггер: Продавец хочет временно убрать курс из каталога (без удаления)
Полный поток:
→ В /seller/items находит активный айтем
→ Нажимает переключатель (toggle) "Видимость" или кнопку "Скрыть из каталога"
→ Диалог: "Скрыть курс? Он перестанет отображаться в поиске для пользователей."
→ [Отмена] [Скрыть]
→ item_isvisible = false
→ Бейдж на карточке меняется: "Активен" → "Скрыт"
→ Toast: "Курс скрыт из каталога. Пользователи не видят его в поиске."
→ Кнопка меняется на "Показать в каталоге"
Обратно:
→ Нажимает "Показать в каталоге"
→ Без диалога: мгновенное действие
→ item_isvisible = true
→ Toast: "Курс снова виден в каталоге."
UC-05: Удаление айтема
Актор: Seller (Owner / Admin CRM) Предусловие: Айтем существует Триггер: Продавец нажимает "Удалить" на карточке айтема
Полный поток:
→ В /seller/items нажимает "Удалить" (или в меню "⋯")
→ Диалог подтверждения:
"Удалить курс «{название}»?
Это действие нельзя отменить. Все данные курса, включая отзывы и статистику, будут удалены."
→ [Отмена] [Удалить навсегда]
→ Айтем переходит в статус archived (soft delete)
→ Исчезает из /seller/items (или остаётся с пометкой "удалён" — TBD)
→ Toast: "Курс «{название}» удалён."
Альтернативные потоки:
a. Попытка удалить айтем с активными лидами:
UI-реакция:
→ Диалог добавляет предупреждение:
"У этого курса есть 3 необработанных заявки. Рекомендуем обработать их перед удалением."
→ [Отмена] [Перейти к заявкам] [Удалить всё равно]
UC-06: Управление ценами (Price Matrix)
Актор: Seller Предусловие: Шаг 3 формы создания/редактирования айтема
Поток добавления варианта цены:
→ Нажимает "+ Добавить вариант оплаты"
→ Появляется блок:
Тип оплаты *: [За занятие ▼] (dropdown)
Значения dropdown:
- "За одно занятие"
- "В месяц (абонемент)"
- "За пакет занятий"
- "Разовый взнос (вступительный / материалы)"
Если "За пакет занятий" — дополнительное поле:
Количество занятий *: [ ] (число)
Цена *: [ ] UZS / USD (переключатель валюты)
Описание (необязательно): до 100 символов
Пометка "Самый выгодный" (чекбокс) — подсвечивает вариант в UI
→ Нажимает "Сохранить вариант"
Пример заполненной матрицы:
✓ За одно занятие 200 000 UZS
✓ В месяц (8 занятий) 1 400 000 UZS ⭐ Самый выгодный
✓ За пакет (4 занятия) 700 000 UZS
✓ Пробное занятие 50 000 UZS Первое занятие по сниженной цене
UC-07: Управление спецпредложениями
Актор: Seller Предусловие: Айтем создан (любой статус кроме archived)
Поток создания спецпредложения:
→ В форме редактирования айтема — вкладка "Акции"
(или в /seller/items/[id]/offers)
→ Нажимает "+ Добавить акцию"
→ Форма:
Название акции *: "Скидка 20% новым ученикам" (до 100 символов)
Тип скидки *: [Процент % / Фиксированная сумма] (radio)
Размер скидки *: [ 20 ] % или [ 50 000 ] UZS
Условие применения * (мультивыбор):
☐ Только новые клиенты
☐ При оплате за 2+ месяца вперёд
☐ Без условий (для всех)
Период действия: Начало [ ] — Конец [ ] (date picker, необязательно)
Отображать на сайте: ☑ (чекбокс, default: включено)
→ "Сохранить акцию"
→ Бейдж акции появляется на карточке айтема в каталоге
UC-08: Управление медиа (фото и видео)
Актор: Seller Предусловие: Айтем создан
Поток загрузки фото:
→ Секция "Фото" на Шаге 4 или в редактировании
→ Drag-and-drop зона или кнопка "Загрузить фото"
→ Загружает файл → показывается прогресс-бар
→ Превью появляется в сетке
→ Можно перетащить для изменения порядка (первое фото — обложка)
→ Кнопка "✕" на превью для удаления отдельного фото
→ Первое фото автоматически становится cover_image_url
Поток добавления видео:
→ Секция "Видео"
→ Поле: "Ссылка на YouTube или Vimeo"
→ При вставке валидной ссылки: мгновенный превью thumbnai
→ При вставке невалидной ссылки: ошибка сразу (не ждём отправки формы)
→ Максимум 3 видео-ссылки
→ Кнопка "✕" для удаления
UC-09: Привязка преподавателей к айтему
Актор: Seller Предусловие: Айтем создан, у продавца есть сотрудники с ролью Teacher/Performer (Spec 03)
Поток:
→ Шаг 5 формы или вкладка "Преподаватели" в редактировании
→ Список сотрудников организации с ролью Teacher/Performer:
Отображается: фото, имя, специализация
→ Мультивыбор через чекбоксы
→ Порядок выбранных преподавателей можно изменить (drag-and-drop)
→ Первый в порядке — главный преподаватель на карточке
→ "Сохранить"
Если у продавца нет ни одного преподавателя:
UI-реакция:
→ Шаг 5: пустое состояние с иконкой
→ Текст: "У вас пока нет добавленных преподавателей."
→ Кнопка: "Добавить преподавателя" → открывает /seller/staff/new в новой вкладке
→ Кнопка: "Пропустить — добавить позже"
4. Бизнес-правила и валидации
Валидация полей
| Поле | Правило | Ошибка пользователю |
|---|---|---|
| Название | 3–200 символов | "Название: от 3 до 200 символов" |
| Краткое описание | 10–300 символов | "Краткое описание: от 10 до 300 символов" |
| Полное описание | 50–5000 символов | "Описание: от 50 до 5000 символов" |
| Чему научится | 20–2000 символов | "Укажите минимум 20 символов" |
| Возраст от | 1–100 лет, ≤ возраст_до | "Возраст «от» должен быть меньше «до»" |
| Класс от | 1–11, ≤ класс_до | "Класс «от» должен быть ≤ «до»" |
| Длительность занятия | > 0 | "Укажите корректную длительность" |
| Обложка (cover) | JPG/PNG/WebP, max 10 МБ, min 800×600px | "Мин. 800×600px, max 10 МБ, форматы JPG/PNG/WebP" |
| Доп. фото | max 10 штук, те же требования | "Максимум 10 фотографий" |
| Видео | YouTube или Vimeo URL, max 3 | "Только ссылки YouTube и Vimeo" |
| Цена | ≥ 0 (0 = бесплатно) | Предупреждение при 0, не ошибка |
| Кол-во вариантов цены | 1–8 | "Минимум 1 вариант. Максимум 8." |
| Спецпредложение — скидка | 1–99% или > 0 UZS | "Укажите размер скидки" |
| Координаты (если новый адрес) | Bounds Узбекистана | "Адрес должен быть в Узбекистане" |
Бизнес-правила
- Видимость и статус: только
active+item_isvisible = trueотображается в каталоге - Pending блокирует редактирование: айтем в pending нельзя редактировать (только отозвать)
- Редактирование active: при сохранении изменений active айтема он уходит в pending на повторную модерацию
- Slug генерируется автоматически из названия айтема (транслитерация) + суффикс из ID (для уникальности). Slug не меняется при редактировании названия.
- item_price_from / item_price_to вычисляются автоматически из матрицы цен (min и max).
- Порядок фото: первое фото в массиве = обложка (
cover_image_url). При удалении обложки — следующее становится обложкой. - Спецпредложение с датой: после
end_dateспецпредложение автоматически деактивируется (cron job), но не удаляется. - Максимум айтемов: нет жёсткого лимита на количество айтемов у продавца (на MVP).
- Soft delete: удалённые айтемы переходят в статус
archived, не удаляются физически (сохраняем историю лидов). - Автосохранение черновика: каждые 30 секунд при активном редактировании.
5. Модель данных
Item (центральная сущность, адаптация существующей)
| Атрибут | Тип | Описание |
|---|---|---|
| item_id | UUID | PK |
| seller_id | UUID FK | → Seller |
| item_name | string | 3–200 символов |
| item_slug | string | URL-slug, уникальный, auto-generated |
| item_shortdesc | string | до 300 символов, для карточки каталога |
| item_desc | text | до 5000 символов, для страницы айтема |
| item_outcomes | text | до 2000 символов, чему научится ученик |
| subject_id | UUID FK | → subject_registry |
| subject_group_id | UUID FK | → subject_group_registry |
| item_studytype | StudyType | group / mini_group / one_on_one |
| item_studyformat | StudyFormat | online / offline / hybrid |
| item_language | Language | ru / uz_latin / uz_cyrillic / en / kk / tg / any |
| item_timeslot | string[] | ['morning', 'afternoon', 'evening'] |
| item_age_from | int? | от лет |
| item_age_to | int? | до лет |
| item_classes_from | int? | от класса |
| item_classes_to | int? | до класса |
| item_duration_minutes | int | длительность занятия в минутах |
| item_course_duration | string? | "3 месяца", "8 недель" — текстовое поле |
| item_price_from | Decimal | auto: min из price_variants |
| item_price_to | Decimal | auto: max из price_variants |
| cover_image_url | string? | URL обложки в CDN |
| moderation_status | ItemStatus | draft/pending/active/rejected/revision_required/archived |
| moderation_comment | text? | Комментарий от админа (rejected / revision_required) |
| item_isvisible | boolean | default: true (toggle продавца, применяется только для active) |
| created_at | DateTime | |
| updated_at | DateTime |
ItemLocation (адрес конкретного айтема, может отличаться от адреса школы)
| Атрибут | Тип | Описание |
|---|---|---|
| id | UUID | PK |
| item_id | UUID FK | → Item |
| seller_address_id | UUID FK? | → SellerAddress (если из сохранённых) |
| city | string | |
| full_address | string? | Если display_publicly |
| latitude | Decimal? | |
| longitude | Decimal? | |
| display_publicly | boolean | default: true |
ItemPriceVariant (матрица цен)
| Атрибут | Тип | Описание |
|---|---|---|
| variant_id | UUID | PK |
| item_id | UUID FK | → Item |
| price_type | PriceType | per_lesson / per_month / per_package / one_time |
| lessons_count | int? | Только для per_package |
| amount | Decimal | ≥ 0 |
| currency | Currency | UZS / USD |
| description | string? | до 100 символов |
| is_highlighted | boolean | default: false ("Самый выгодный") |
| sort_order | int | Порядок отображения |
ItemSpecialOffer (спецпредложения)
| Атрибут | Тип | Описание |
|---|---|---|
| offer_id | UUID | PK |
| item_id | UUID FK | → Item |
| title | string | до 100 символов |
| discount_type | DiscountType | percent / fixed_amount |
| discount_value | Decimal | % или сумма |
| currency | Currency? | Только для fixed_amount |
| condition | OfferCondition | new_clients / prepay / all |
| starts_at | DateTime? | |
| ends_at | DateTime? | |
| is_active | boolean | default: true |
| show_on_site | boolean | default: true |
ItemMedia (фото и видео)
| Атрибут | Тип | Описание |
|---|---|---|
| media_id | UUID | PK |
| item_id | UUID FK | → Item |
| media_type | MediaType | photo / video_link |
| url | string | CDN URL для фото / YouTube/Vimeo URL для видео |
| sort_order | int | Порядок (0 = обложка) |
| created_at | DateTime |
ItemPerformerLink (привязка преподавателей к айтему)
| Атрибут | Тип | Описание |
|---|---|---|
| id | UUID | PK |
| item_id | UUID FK | → Item |
| seller_staff_id | UUID FK | → SellerStaff (Spec 03) |
| sort_order | int | Порядок (0 = главный преподаватель) |
| @@unique([item_id, seller_staff_id]) |
6. Технические контракты
6.1 Prisma Schema (добавления)
enum ItemStatus {
draft
pending
active
rejected
revision_required
archived
}
enum StudyType {
group
mini_group
one_on_one
}
enum StudyFormat {
online
offline
hybrid
}
enum Language {
ru
uz_latin
uz_cyrillic
en
kk
tg
any
}
enum PriceType {
per_lesson
per_month
per_package
one_time
}
enum Currency {
UZS
USD
}
enum DiscountType {
percent // скидка в процентах
fixed_amount // скидка фиксированной суммой
gift // подарок / бонусное занятие
}
enum OfferCondition {
new_clients
prepay
all
}
enum MediaType {
photo
video_link
}
model Item {
item_id String @id @default(uuid())
seller_id String
item_name String @db.VarChar(200)
item_slug String @unique
item_shortdesc String @db.VarChar(300)
item_desc String @db.Text
item_outcomes String @db.Text
subject_id String
subject_group_id String
item_studytype StudyType
item_studyformat StudyFormat
item_language Language
item_timeslot String[] // ['morning', 'afternoon', 'evening']
item_age_from Int?
item_age_to Int?
item_classes_from Int?
item_classes_to Int?
item_duration_minutes Int
item_course_duration String?
item_price_from Decimal @db.Decimal(12, 2) @default(0)
item_price_to Decimal @db.Decimal(12, 2) @default(0)
cover_image_url String?
moderation_status ItemStatus @default(draft)
moderation_comment String? @db.Text
item_isvisible Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
seller Seller @relation(fields: [seller_id], references: [seller_id])
location ItemLocation?
price_variants ItemPriceVariant[]
special_offers ItemSpecialOffer[]
media ItemMedia[]
performers ItemPerformerLink[]
leads Lead[] // → Spec 09
reviews Review[] // → Spec 14
}
model ItemLocation {
id String @id @default(uuid())
item_id String @unique
seller_address_id String?
city String
full_address String?
latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7)
display_publicly Boolean @default(true)
item Item @relation(fields: [item_id], references: [item_id])
seller_address SellerAddress? @relation(fields: [seller_address_id], references: [address_id])
}
model ItemPriceVariant {
variant_id String @id @default(uuid())
item_id String
price_type PriceType
lessons_count Int?
amount Decimal @db.Decimal(12, 2)
currency Currency @default(UZS)
description String? @db.VarChar(100)
is_highlighted Boolean @default(false)
sort_order Int @default(0)
item Item @relation(fields: [item_id], references: [item_id])
}
model ItemSpecialOffer {
offer_id String @id @default(uuid())
item_id String
title String @db.VarChar(100)
discount_type DiscountType
discount_value Decimal @db.Decimal(10, 2)
currency Currency?
condition OfferCondition @default(all)
starts_at DateTime?
ends_at DateTime?
is_active Boolean @default(true)
show_on_site Boolean @default(true)
item Item @relation(fields: [item_id], references: [item_id])
}
model ItemMedia {
media_id String @id @default(uuid())
item_id String
media_type MediaType
url String
sort_order Int @default(0)
created_at DateTime @default(now())
item Item @relation(fields: [item_id], references: [item_id])
}
model ItemPerformerLink {
id String @id @default(uuid())
item_id String
seller_staff_id String
sort_order Int @default(0)
item Item @relation(fields: [item_id], references: [item_id])
staff SellerStaff @relation(fields: [seller_staff_id], references: [staff_id])
@@unique([item_id, seller_staff_id])
}
6.2 TypeScript DTO
// ─── Создание / редактирование ───────────────────────────────────────────
export class CreateItemStep1Dto {
@IsString() @MinLength(3) @MaxLength(200)
item_name: string
@IsUUID()
subject_id: string
@IsString() @MinLength(10) @MaxLength(300)
item_shortdesc: string
@IsString() @MinLength(50) @MaxLength(5000)
item_desc: string
@IsString() @MinLength(20) @MaxLength(2000)
item_outcomes: string
}
export class CreateItemStep2Dto {
@IsEnum(StudyType)
item_studytype: StudyType
@IsEnum(StudyFormat)
item_studyformat: StudyFormat
@IsEnum(Language)
item_language: Language
@IsArray() @IsIn(['morning', 'afternoon', 'evening'], { each: true })
item_timeslot: string[]
@IsOptional() @IsInt() @Min(1) @Max(100)
item_age_from?: number
@IsOptional() @IsInt() @Min(1) @Max(100)
item_age_to?: number
@IsOptional() @IsInt() @Min(1) @Max(11)
item_classes_from?: number
@IsOptional() @IsInt() @Min(1) @Max(11)
item_classes_to?: number
@IsInt() @Min(1)
item_duration_minutes: number
@IsOptional() @IsString() @MaxLength(50)
item_course_duration?: string
// Только для offline/hybrid:
@IsOptional() @ValidateNested()
location?: CreateItemLocationDto
}
export class CreateItemLocationDto {
@IsOptional() @IsUUID()
seller_address_id?: string // если из существующих адресов
// Или ручной ввод нового адреса:
@IsOptional() @IsString()
city?: string
@IsOptional() @IsString()
full_address?: string
@IsOptional() @Min(37.0) @Max(45.6)
latitude?: number
@IsOptional() @Min(55.9) @Max(73.2)
longitude?: number
@IsBoolean() @IsOptional()
display_publicly?: boolean // default: true
}
export class CreatePriceVariantDto {
@IsEnum(PriceType)
price_type: PriceType
@IsOptional() @IsInt() @Min(1)
lessons_count?: number // только для per_package
@IsNumber() @Min(0)
amount: number
@IsEnum(Currency) @IsOptional()
currency?: Currency // default: UZS
@IsOptional() @IsString() @MaxLength(100)
description?: string
@IsBoolean() @IsOptional()
is_highlighted?: boolean
}
export class CreateSpecialOfferDto {
@IsString() @MaxLength(100)
title: string
@IsEnum(DiscountType)
discount_type: DiscountType
@IsNumber() @Min(0.01)
discount_value: number
@IsOptional() @IsEnum(Currency)
currency?: Currency
@IsEnum(OfferCondition)
condition: OfferCondition
@IsOptional() @IsDateString()
starts_at?: string
@IsOptional() @IsDateString()
ends_at?: string
@IsBoolean() @IsOptional()
show_on_site?: boolean // default: true
}
// ─── Response ─────────────────────────────────────────────────────────────
export interface ItemCardResponse { // для списка в /seller/items
item_id: string
item_name: string
item_slug: string
cover_image_url: string | null
moderation_status: ItemStatus
moderation_comment: string | null
item_isvisible: boolean
item_studytype: StudyType
item_studyformat: StudyFormat
item_price_from: number
item_price_to: number
subject: SubjectShortDto
leads_count: number
reviews_count: number
created_at: string
updated_at: string
}
export interface ItemFullResponse { // для формы редактирования
item_id: string
item_name: string
item_slug: string
item_shortdesc: string
item_desc: string
item_outcomes: string
subject_id: string
item_studytype: StudyType
item_studyformat: StudyFormat
item_language: Language
item_timeslot: string[]
item_age_from: number | null
item_age_to: number | null
item_classes_from: number | null
item_classes_to: number | null
item_duration_minutes: number
item_course_duration: string | null
item_price_from: number
item_price_to: number
cover_image_url: string | null
moderation_status: ItemStatus
moderation_comment: string | null
item_isvisible: boolean
location: ItemLocationDto | null
price_variants: PriceVariantDto[]
special_offers: SpecialOfferDto[]
media: ItemMediaDto[]
performers: PerformerShortDto[]
}
6.3 API Endpoints
────────────────────────────────────────────────────────────────
SELLER: УПРАВЛЕНИЕ АЙТЕМАМИ
────────────────────────────────────────────────────────────────
GET /api/seller/items
Auth: Bearer (seller)
Query: ?status=active&page=1&limit=20
→ 200: { items: ItemCardResponse[], total: number, page: number }
POST /api/seller/items
Auth: Bearer (seller)
Body: CreateItemStep1Dto & CreateItemStep2Dto & { price_variants: CreatePriceVariantDto[] }
→ 201: ItemFullResponse
→ 422: { errors: [{ field: string, message: string }] }
POST /api/seller/items/draft
Auth: Bearer (seller)
Body: Partial<CreateItemDto> // автосохранение черновика, неполные данные допустимы
→ 201: { item_id: string, moderation_status: 'draft' }
GET /api/seller/items/:item_id
Auth: Bearer (seller)
→ 200: ItemFullResponse
→ 403: { error: 'FORBIDDEN' } // чужой айтем
→ 404: { error: 'ITEM_NOT_FOUND' }
PATCH /api/seller/items/:item_id
Auth: Bearer (seller)
Body: Partial<ItemFullResponse> // частичное обновление
→ 200: ItemFullResponse
→ 400: { error: 'ITEM_PENDING', message: 'Айтем на проверке, редактирование недоступно.' }
→ 422: { errors: ValidationError[] }
DELETE /api/seller/items/:item_id
Auth: Bearer (seller: owner | admin_crm only)
→ 204
→ 403: { error: 'INSUFFICIENT_ROLE', message: 'Только владелец или администратор может удалять курсы.' }
POST /api/seller/items/:item_id/submit
Auth: Bearer (seller)
→ 200: { item_id: string, moderation_status: 'pending' }
→ 400: { error: 'ALREADY_PENDING' | 'MISSING_REQUIRED_FIELDS', message: string }
→ 422: { missing_fields: string[] } // какие поля нужно заполнить
POST /api/seller/items/:item_id/withdraw
Auth: Bearer (seller)
Body: {} // отозвать с модерации
→ 200: { item_id: string, moderation_status: 'draft' }
→ 400: { error: 'NOT_PENDING', message: 'Айтем не находится на проверке.' }
PATCH /api/seller/items/:item_id/visibility
Auth: Bearer (seller)
Body: { is_visible: boolean }
→ 200: { item_id: string, item_isvisible: boolean }
→ 400: { error: 'NOT_ACTIVE', message: 'Управление видимостью доступно только для активных курсов.' }
────────────────────────────────────────────────────────────────
SELLER: ЦЕНЫ
────────────────────────────────────────────────────────────────
POST /api/seller/items/:item_id/price-variants
Auth: Bearer (seller)
Body: CreatePriceVariantDto
→ 201: PriceVariantDto
→ 400: { error: 'MAX_VARIANTS_REACHED', message: 'Максимум 8 вариантов оплаты.' }
PATCH /api/seller/items/:item_id/price-variants/:variant_id
Auth: Bearer (seller)
Body: Partial<CreatePriceVariantDto>
→ 200: PriceVariantDto
DELETE /api/seller/items/:item_id/price-variants/:variant_id
Auth: Bearer (seller)
→ 204
→ 400: { error: 'LAST_VARIANT', message: 'Необходим минимум 1 вариант оплаты.' }
────────────────────────────────────────────────────────────────
SELLER: СПЕЦПРЕДЛОЖЕНИЯ
────────────────────────────────────────────────────────────────
POST /api/seller/items/:item_id/offers
Auth: Bearer (seller)
Body: CreateSpecialOfferDto
→ 201: SpecialOfferDto
PATCH /api/seller/items/:item_id/offers/:offer_id
Auth: Bearer (seller)
Body: Partial<CreateSpecialOfferDto>
→ 200: SpecialOfferDto
DELETE /api/seller/items/:item_id/offers/:offer_id
Auth: Bearer (seller)
→ 204
────────────────────────────────────────────────────────────────
SELLER: МЕДИА
────────────────────────────────────────────────────────────────
POST /api/seller/items/:item_id/media
Auth: Bearer (seller)
Body: multipart/form-data { file: File } | { video_url: string }
→ 201: ItemMediaDto
→ 400: { error: 'MAX_PHOTOS_REACHED' | 'MAX_VIDEOS_REACHED' | 'INVALID_VIDEO_URL' | 'FILE_TOO_LARGE' | 'INVALID_FORMAT', message: string }
PATCH /api/seller/items/:item_id/media/reorder
Auth: Bearer (seller)
Body: { media_ids: string[] } // новый порядок
→ 200: ItemMediaDto[]
DELETE /api/seller/items/:item_id/media/:media_id
Auth: Bearer (seller)
→ 204
────────────────────────────────────────────────────────────────
SELLER: ПРЕПОДАВАТЕЛИ НА АЙТЕМЕ
────────────────────────────────────────────────────────────────
PATCH /api/seller/items/:item_id/performers
Auth: Bearer (seller)
Body: { performer_ids: string[], order: string[] }
→ 200: PerformerShortDto[]
→ 400: { error: 'STAFF_NOT_IN_ORG', message: 'Некоторые сотрудники не принадлежат вашей организации.' }
────────────────────────────────────────────────────────────────
SLUG GENERATION (internal utility)
────────────────────────────────────────────────────────────────
Slug генерируется на сервере при создании айтема:
transliterate(item_name) + '-' + shortId(item_id, 6)
Пример: "angliiskii-dlya-detei-a1b2c3"
Slug НЕИЗМЕНЯЕМ после создания (важно для SEO).
7. Edge Cases и обработка ошибок
| Сценарий | Поведение |
|---|---|
| Продавец пытается отредактировать чужой айтем | 403 FORBIDDEN |
| Slug сгенерирован, но уже существует | Добавляем числовой суффикс: angliiskii-dlya-detei-a1b2c3-2 |
| item_price_from/to пересчёт при удалении варианта цены | Триггер или сервис пересчитывает после каждого изменения price_variants |
| Спецпредложение с ends_at в прошлом при создании | 400: "Дата окончания акции не может быть в прошлом." |
| Спецпредложение с ends_at истекло (cron job) | is_active = false, бейдж пропадает с карточки |
| Удаление last price variant | 400 LAST_VARIANT |
| Загрузка 11-й фотографии | 400 MAX_PHOTOS_REACHED |
| Изображение слишком маленькое (< 800×600) | 422 INVALID_DIMENSIONS — сервер проверяет размеры |
| Изображение с прозрачностью (PNG) конвертируется | Сервер конвертирует в JPG с белым фоном (или оставляем PNG — TBD) |
| Параллельное редактирование айтема двумя сессиями | Последняя запись побеждает (last-write-wins), предупреждение не показываем (MVP) |
| Seller деактивирован (under_review/blocked), его айтемы | Все айтемы автоматически скрываются из публичного каталога (фильтр по seller.account_status) |
| Айтем в pending, admin долго не проверяет (> 3 дней) | Уведомление продавцу не реализуется в MVP — TBD |
8. TBD / Сознательно опущено
| Тема | Статус | Примечание |
|---|---|---|
| S3/CDN для медиа | TBD | Провайдер не определён (AWS S3, Cloudflare R2, MinIO). Метод uploadMedia() — абстракция |
| Конвертация PNG в JPG | TBD | Нужно ли конвертировать PNG с прозрачностью — не определено |
| Расписание айтема (Schedule) | Исключено из MVP | В MVP времени суток достаточно. Конкретное расписание — v1.0 (CRM) |
| Максимум айтемов у продавца | TBD | Лимит не определён. На MVP — без лимита |
| Версионирование айтемов | Исключено из MVP | История изменений для аналитики — через EventLog/SAA |
| Оптимистичные обновления UI | TBD | При изменении visibility — мгновенный отклик UI до ответа сервера или wait? |
| Conflict detection (2 сессии) | Исключено из MVP | Реализовать в v1.0 через updated_at проверку |
| Уведомление при долгой модерации | Исключено из MVP | v1.0 |
| Bulk operations (удалить/скрыть несколько) | Исключено из MVP | v1.0 |
| Draft autosave interval | TBD | Каждые 30 секунд — нужно подтвердить, не будет ли нагрузки на БД |
| Analytical shadow (SAA) | TBD | item_profile → s_item_name, s_item_price и т.д. ETL отдельно |
| Изображения: генерация thumbnails | TBD | Размеры thumbnails для карточки каталога vs. страницы айтема не определены |
Зависимости
| Модуль | Связь |
|---|---|
| Spec 01 (Seller Profile) | Item.seller_id → Seller.seller_id; ItemLocation может ссылаться на SellerAddress |
| Spec 03 (Staff) | ItemPerformerLink.seller_staff_id → SellerStaff |
| Spec 04 (Admin Moderation) | Изменение Item.moderation_status через admin endpoints |
| Spec 05 (Catalog) | Каталог читает только active + isvisible=true айтемы |
| Spec 06 (Item Detail Page) | Публичная страница читает полный ItemFullResponse |
| Spec 07 (Lead Submission) | Lead.item_id → Item.item_id |
| Spec 14 (Reviews) | Review.item_id → Item.item_id |
| Spec 16 (Reference Data) | Item.subject_id → subject_registry |