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

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любое❌ Нет✅ Да, с бейджем "На проверке"
activetrue✅ Да✅ Да, с бейджем "Активен"
activefalse❌ Нет✅ Да, с бейджем "Скрыт"
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 Узбекистана"Адрес должен быть в Узбекистане"

Бизнес-правила

  1. Видимость и статус: только active + item_isvisible = true отображается в каталоге
  2. Pending блокирует редактирование: айтем в pending нельзя редактировать (только отозвать)
  3. Редактирование active: при сохранении изменений active айтема он уходит в pending на повторную модерацию
  4. Slug генерируется автоматически из названия айтема (транслитерация) + суффикс из ID (для уникальности). Slug не меняется при редактировании названия.
  5. item_price_from / item_price_to вычисляются автоматически из матрицы цен (min и max).
  6. Порядок фото: первое фото в массиве = обложка (cover_image_url). При удалении обложки — следующее становится обложкой.
  7. Спецпредложение с датой: после end_date спецпредложение автоматически деактивируется (cron job), но не удаляется.
  8. Максимум айтемов: нет жёсткого лимита на количество айтемов у продавца (на MVP).
  9. Soft delete: удалённые айтемы переходят в статус archived, не удаляются физически (сохраняем историю лидов).
  10. Автосохранение черновика: каждые 30 секунд при активном редактировании.

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

Item (центральная сущность, адаптация существующей)

АтрибутТипОписание
item_idUUIDPK
seller_idUUID FK→ Seller
item_namestring3–200 символов
item_slugstringURL-slug, уникальный, auto-generated
item_shortdescstringдо 300 символов, для карточки каталога
item_desctextдо 5000 символов, для страницы айтема
item_outcomestextдо 2000 символов, чему научится ученик
subject_idUUID FK→ subject_registry
subject_group_idUUID FK→ subject_group_registry
item_studytypeStudyTypegroup / mini_group / one_on_one
item_studyformatStudyFormatonline / offline / hybrid
item_languageLanguageru / uz_latin / uz_cyrillic / en / kk / tg / any
item_timeslotstring[]['morning', 'afternoon', 'evening']
item_age_fromint?от лет
item_age_toint?до лет
item_classes_fromint?от класса
item_classes_toint?до класса
item_duration_minutesintдлительность занятия в минутах
item_course_durationstring?"3 месяца", "8 недель" — текстовое поле
item_price_fromDecimalauto: min из price_variants
item_price_toDecimalauto: max из price_variants
cover_image_urlstring?URL обложки в CDN
moderation_statusItemStatusdraft/pending/active/rejected/revision_required/archived
moderation_commenttext?Комментарий от админа (rejected / revision_required)
item_isvisiblebooleandefault: true (toggle продавца, применяется только для active)
created_atDateTime
updated_atDateTime

ItemLocation (адрес конкретного айтема, может отличаться от адреса школы)

АтрибутТипОписание
idUUIDPK
item_idUUID FK→ Item
seller_address_idUUID FK?→ SellerAddress (если из сохранённых)
citystring
full_addressstring?Если display_publicly
latitudeDecimal?
longitudeDecimal?
display_publiclybooleandefault: true

ItemPriceVariant (матрица цен)

АтрибутТипОписание
variant_idUUIDPK
item_idUUID FK→ Item
price_typePriceTypeper_lesson / per_month / per_package / one_time
lessons_countint?Только для per_package
amountDecimal≥ 0
currencyCurrencyUZS / USD
descriptionstring?до 100 символов
is_highlightedbooleandefault: false ("Самый выгодный")
sort_orderintПорядок отображения

ItemSpecialOffer (спецпредложения)

АтрибутТипОписание
offer_idUUIDPK
item_idUUID FK→ Item
titlestringдо 100 символов
discount_typeDiscountTypepercent / fixed_amount
discount_valueDecimal% или сумма
currencyCurrency?Только для fixed_amount
conditionOfferConditionnew_clients / prepay / all
starts_atDateTime?
ends_atDateTime?
is_activebooleandefault: true
show_on_sitebooleandefault: true

ItemMedia (фото и видео)

АтрибутТипОписание
media_idUUIDPK
item_idUUID FK→ Item
media_typeMediaTypephoto / video_link
urlstringCDN URL для фото / YouTube/Vimeo URL для видео
sort_orderintПорядок (0 = обложка)
created_atDateTime

ItemPerformerLink (привязка преподавателей к айтему)

АтрибутТипОписание
idUUIDPK
item_idUUID FK→ Item
seller_staff_idUUID FK→ SellerStaff (Spec 03)
sort_orderintПорядок (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 variant400 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 в JPGTBDНужно ли конвертировать PNG с прозрачностью — не определено
Расписание айтема (Schedule)Исключено из MVPВ MVP времени суток достаточно. Конкретное расписание — v1.0 (CRM)
Максимум айтемов у продавцаTBDЛимит не определён. На MVP — без лимита
Версионирование айтемовИсключено из MVPИстория изменений для аналитики — через EventLog/SAA
Оптимистичные обновления UITBDПри изменении visibility — мгновенный отклик UI до ответа сервера или wait?
Conflict detection (2 сессии)Исключено из MVPРеализовать в v1.0 через updated_at проверку
Уведомление при долгой модерацииИсключено из MVPv1.0
Bulk operations (удалить/скрыть несколько)Исключено из MVPv1.0
Draft autosave intervalTBDКаждые 30 секунд — нужно подтвердить, не будет ли нагрузки на БД
Analytical shadow (SAA)TBDitem_profile → s_item_name, s_item_price и т.д. ETL отдельно
Изображения: генерация thumbnailsTBDРазмеры 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