# MVP Spec 02 — Item / Course Management

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

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> 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 Узбекистана | "Адрес должен быть в Узбекистане" |

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

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_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 (добавления)

```prisma
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

```typescript
// ─── Создание / редактирование ───────────────────────────────────────────

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 |
