# MVP Spec 15 — Reference Data 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: P1 · Phase: A (Supply)
> Status: Draft v1
> Sync note, 28 Mar 2026:
> - live API prefix is `/api/v1/`, not `/api/`;
> - current public endpoints: `GET /api/v1/subjects`, `GET /api/v1/catalog/subjects`, `GET /api/v1/catalog/locations`;
> - current admin CRUD lives under `/api/v1/catalog/subjects*` and `/api/v1/catalog/locations*`, not under historical `/api/admin/ref/*` namespace in old draft fragments.

---

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

Reference Data — это справочники платформы, от которых зависят все остальные модули: предметы/направления в каталоге, города и районы для фильтрации, теги для поиска. Без актуальных и структурированных справочников невозможно ни создание курсов (Spec 02), ни работа каталога (Spec 05), ни онбординг продавца (Spec 01).

**Цель модуля:** дать администраторам возможность управлять всеми справочниками через единый раздел /admin/reference, а продавцам и публичным пользователям — получать справочные данные через публичные API для использования в формах и фильтрах.

**Что входит в этот модуль:**
- Управление группами предметов и предметами (двухуровневая иерархия)
- Управление локациями (города и районы)
- Управление тегами
- Публичные API для всех справочников (для дропдаунов и фильтров)
- Redis-кэширование справочников (TTL 30 мин)

**Что не входит в этот модуль:**
- Привязка предметов к продавцам (SellerSubjectLink) → Spec 01
- Привязка предметов к айтемам (ItemSubjectLink) → Spec 02
- Фильтрация каталога по предметам и локациям → Spec 05
- Управление статусами модерации → Spec 04

---

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

| Роль | Действия в этом модуле |
|------|----------------------|
| **Root Admin** | Полный CRUD: группы предметов, предметы, локации, теги |
| **Marketer** | Полный CRUD: группы предметов, предметы, локации, теги |
| **Верификатор** | Только просмотр справочников |
| **Seller / Guest** | Только публичные GET API (без аутентификации) |

---

## 3. Use Cases

---

### UC-01: Admin создаёт группу предметов

**Актор:** Admin (Root или Marketer)
**Предусловие:** Admin авторизован на /admin
**Триггер:** Admin хочет добавить новую категорию верхнего уровня

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

```
[Точка входа]
→ Admin находится в /admin/reference/subjects
→ Видит страницу с двумя колонками: "Группы" и "Предметы в группе"
→ В колонке "Группы" нажимает кнопку "+ Добавить группу"
→ Открывается модальное окно "Новая группа предметов"

───────────────────────────────────────────────────────
ФОРМА — Создание группы предметов
───────────────────────────────────────────────────────
→ Поля:
    - Название (UZ) * (до 100 символов)
    - Название (RU) * (до 100 символов)
    - Название (EN)  (до 100 символов, необязательно)
    - Slug * (до 80 символов, только латиница/цифры/дефис; автогенерируется из UZ-названия)
    - Порядок сортировки (число, default: 0)
→ Slug генерируется автоматически при вводе UZ-названия (транслитерация Кириллица→Латиница)
→ Slug редактируемый вручную
→ Кнопки: [Отмена] [Создать группу]
→ Admin заполняет поля → нажимает "Создать группу"
→ Система проверяет уникальность slug
→ Создаёт SubjectGroup
→ Модал закрывается
→ Новая группа появляется в колонке "Группы"
→ Toast (зелёный): "Группа «{name_uz}» создана."
→ Redis-кэш ref:subjects инвалидируется
```

---

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

**1a. Slug уже существует:**
```
Триггер: Сервер возвращает 409 SLUG_TAKEN

UI-реакция:
→ Поле Slug: красная обводка + ⚠
→ Под полем: "Этот slug уже занят. Введите другой."
→ Кнопка "Создать группу" остаётся заблокированной
```

**1b. Обязательное поле пустое:**
```
Триггер: Admin нажал "Создать группу" не заполнив обязательное поле

UI-реакция:
→ Пустые обязательные поля: красная обводка + ⚠
→ Под каждым: "Это поле обязательно для заполнения"
→ Страница прокручивается к первому полю с ошибкой
```

**1c. Slug содержит недопустимые символы (валидация on-blur):**
```
UI-реакция:
→ Поле Slug: красная обводка + ⚠
→ Под полем: "Slug может содержать только латинские буквы, цифры и дефис (a-z, 0-9, -)"
```

**1d. Технический сбой:**
```
UI-реакция:
→ Toast (красный): "Не удалось создать группу. Проверьте соединение и попробуйте снова."
→ Форма не закрывается, данные не сбрасываются
```

---

### UC-02: Admin создаёт предмет внутри группы

**Актор:** Admin (Root или Marketer)
**Предусловие:** Существует хотя бы одна группа предметов
**Триггер:** Admin выбрал группу и нажимает "+ Добавить предмет"

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

```
[Точка входа]
→ Admin находится в /admin/reference/subjects
→ В колонке "Группы" кликает на нужную группу
→ В правой колонке "Предметы в группе {name}" видит список предметов
→ Нажимает "+ Добавить предмет"
→ Открывается модальное окно "Новый предмет"

───────────────────────────────────────────────────────
ФОРМА — Создание предмета
───────────────────────────────────────────────────────
→ Поля:
    - Группа * (предзаполнена выбранной группой, readonly)
    - Название (UZ) * (до 100 символов)
    - Название (RU) * (до 100 символов)
    - Название (EN)  (до 100 символов, необязательно)
    - Slug * (автогенерируется: {group_slug}-{subject_slug})
    - Порядок сортировки (число, default: 0)
    - Активен (toggle, default: true)
→ Admin заполняет → нажимает "Создать предмет"
→ Система проверяет уникальность slug
→ Создаёт Subject
→ Новый предмет появляется в правой колонке
→ Toast: "Предмет «{name_uz}» создан."
→ Redis-кэш ref:subjects инвалидируется
```

**Альтернативные потоки:**

**2a. Slug уже существует:**
```
UI-реакция:
→ Поле Slug: красная обводка + ⚠
→ Под полем: "Slug «{slug}» уже занят. Введите другой."
```

**2b. Группа была удалена пока форма была открыта:**
```
UI-реакция:
→ После сабмита: Toast (красный): "Группа не найдена. Обновите страницу."
→ Форма закрывается, страница перезагружается
```

---

### UC-03: Admin редактирует и деактивирует предмет

**Актор:** Admin (Root или Marketer)
**Предусловие:** Предмет существует в системе
**Триггер:** Admin нажимает кнопку "Редактировать" рядом с предметом

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

```
[Точка входа]
→ Admin находится в /admin/reference/subjects
→ В колонке предметов видит строку с предметом
→ Нажимает иконку карандаша (Edit) → открывается модал "Редактировать предмет"
→ Все поля предзаполнены текущими значениями
→ Admin вносит изменения → "Сохранить"
→ Toast: "Предмет обновлён."
→ Redis-кэш ref:subjects инвалидируется

Деактивация предмета:
→ Admin переключает toggle "Активен" в Off
→ Нажимает "Сохранить"
→ Диалог подтверждения:
    "Деактивация скроет предмет «{name}» из всех форм и фильтров.
     Уже привязанные курсы и продавцы останутся, но предмет перестанет
     предлагаться при создании новых."
→ [Отмена] [Деактивировать]
→ Subject.is_active = false
→ Toast: "Предмет «{name}» деактивирован."
→ Redis-кэш инвалидируется

Удаление предмета:
→ Admin нажимает иконку корзины (Delete)
→ Система проверяет: есть ли привязанные SellerSubjectLink или ItemSubjectLink
→ Если есть привязки: отказ (см. ошибку 3a ниже)
→ Если нет: Диалог "Удалить предмет «{name}»? Это действие нельзя отменить."
→ [Отмена] [Удалить]
→ DELETE Subject
→ Toast: "Предмет удалён."
→ Redis-кэш инвалидируется
```

**Альтернативные потоки:**

**3a. Попытка удалить предмет с привязками:**
```
UI-реакция:
→ Кнопка Delete отображает tooltip: "Нельзя удалить: есть привязанные записи."
→ При попытке через API: 409 SUBJECT_HAS_BINDINGS
→ Toast: "Предмет «{name}» используется в {N} курсах и {M} профилях продавцов.
  Сначала деактивируйте предмет, чтобы убрать его из новых записей."
```

**3b. Попытка изменить slug у предмета с привязками:**
```
UI-реакция:
→ Поле Slug: жёлтая обводка (предупреждение)
→ Под полем: "Изменение slug может нарушить существующие ссылки. Убедитесь, что нет ни одной внешней ссылки на этот адрес."
→ Кнопка "Сохранить" остаётся активной (изменение разрешено, но с предупреждением)
```

---

### UC-04: Admin создаёт локацию (город или район)

**Актор:** Admin (Root или Marketer)
**Предусловие:** Admin авторизован
**Триггер:** Admin переходит в /admin/reference/locations и нажимает "+ Добавить"

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

```
[Точка входа]
→ Admin находится в /admin/reference/locations
→ Видит таблицу локаций с колонками: Название, Тип, Город, Координаты, Статус
→ Нажимает "+ Добавить локацию"
→ Открывается модальное окно "Новая локация"

───────────────────────────────────────────────────────
ФОРМА — Создание локации
───────────────────────────────────────────────────────
→ Поля:
    - Тип * (radio): Город / Район
    - Название (UZ) * (до 100 символов)
    - Название (RU) * (до 100 символов)
    - Город (если тип = Район): выпадающий список городов из Location WHERE type=city
    - Широта (latitude) * (Decimal, bounds: 37.0–45.6)
    - Долгота (longitude) * (Decimal, bounds: 55.9–73.2)
    - Активна (toggle, default: true)
→ Мини-карта: маркер на введённых координатах (обновляется при вводе)
→ Кнопка "Определить координаты по названию" → вызывает Nominatim geocoding
→ Admin заполняет → нажимает "Создать"
→ Создаёт Location
→ Toast: "Локация «{name_uz}» создана."
→ Redis-кэш ref:locations инвалидируется
```

**Альтернативные потоки:**

**4a. Координаты вне bounds Узбекистана:**
```
UI-реакция:
→ Поля lat/lon: красная обводка + ⚠
→ Под полями: "Координаты должны быть в пределах Узбекистана (lat: 37–45.6, lon: 55.9–73.2)"
```

**4b. Nominatim не нашёл адрес:**
```
UI-реакция:
→ Toast (жёлтый): "Не удалось определить координаты автоматически. Введите координаты вручную или переместите маркер на карте."
→ Форма остаётся активной, поля координат не заполняются
```

**4c. Название уже существует (дубликат):**
```
UI-реакция:
→ Поле Название (UZ): красная обводка + ⚠
→ Под полем: "Локация с таким названием уже существует."
```

---

### UC-05: Admin редактирует и удаляет локацию

**Актор:** Admin (Root или Marketer)
**Предусловие:** Локация существует
**Триггер:** Admin нажимает "Редактировать" в строке локации

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

```
→ Открывается модал с предзаполненными данными
→ Admin вносит изменения → "Сохранить"
→ Toast: "Локация обновлена."
→ Redis-кэш ref:locations инвалидируется

Удаление локации:
→ Admin нажимает Delete
→ Система проверяет: используется ли локация в SellerAddress или Item
→ Если используется: отказ (см. 5a)
→ Если нет: Диалог "Удалить «{name}»? Это действие нельзя отменить."
→ [Отмена] [Удалить]
→ DELETE Location
→ Toast: "Локация удалена."
→ Redis-кэш инвалидируется
```

**Альтернативные потоки:**

**5a. Попытка удалить локацию с привязками:**
```
UI-реакция:
→ Tooltip на кнопке Delete: "Нельзя удалить: есть привязанные адреса или курсы."
→ При попытке через API: 409 LOCATION_HAS_BINDINGS
→ Toast: "Локация используется. Деактивируйте её — она перестанет показываться в новых формах, но существующие данные сохранятся."
```

---

### UC-06: Admin управляет тегами

**Актор:** Admin (Root или Marketer)
**Предусловие:** Admin авторизован
**Триггер:** Admin переходит в /admin/reference/tags

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

```
[Точка входа]
→ Admin находится в /admin/reference/tags
→ Видит таблицу: Тип, Значение (raw), Название RU, Название UZ, Видимость
→ Кнопка "+ Добавить тег"

───────────────────────────────────────────────────────
ФОРМА — Создание тега
───────────────────────────────────────────────────────
→ Поля:
    - Тип * (выпадающий список: audience_age, format, duration, level, feature, ...)
    - Raw value * (slug-формат, до 80 символов)
    - Название (RU) * (до 100 символов)
    - Название (UZ) * (до 100 символов)
    - Видимый (is_visible toggle, default: true)
→ Admin заполняет → "Создать тег"
→ Toast: "Тег создан."

Редактирование:
→ Admin нажимает Edit → изменяет поля → "Сохранить"
→ Toast: "Тег обновлён."

Скрытие тега (is_visible = false):
→ Admin переключает toggle "Видимый" → "Сохранить"
→ Тег скрывается из публичных форм, но не удаляется из базы
→ Toast: "Тег скрыт из публичных фильтров."

Удаление тега:
→ Диалог подтверждения: "Удалить тег «{raw_value}»?"
→ DELETE Tag
→ Toast: "Тег удалён."
```

**Альтернативные потоки:**

**6a. Raw value уже существует:**
```
UI-реакция:
→ Поле Raw value: красная обводка + ⚠
→ Под полем: "Этот raw value уже используется."
```

**6b. Попытка удалить тег, привязанный к айтемам:**
```
UI-реакция:
→ 409 TAG_IN_USE
→ Toast: "Тег используется в {N} курсах. Сначала скройте тег (is_visible = false)."
```

---

### UC-07: Публичный API справочных данных

**Актор:** Любой пользователь (без аутентификации) или авторизованный продавец
**Предусловие:** Справочные данные существуют и активны
**Триггер:** Фронтенд загружает дропдаун для онбординга продавца, создания айтема или каталожных фильтров

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

```
[Предметы — для дропдаунов]
→ GET /api/v1/subjects
→ Сервер проверяет Redis-кэш ref:subjects
→ Если кэш есть (TTL < 30 мин): возвращает из кэша
→ Если кэша нет: запрос к БД → SubjectGroup[] c вложенными Subject[]
   (только is_active = true, отсортированные по sort_order)
→ Кэширует результат в Redis с TTL 30 мин
→ Возвращает дерево групп и предметов

[Локации — для дропдаунов]
→ GET /api/v1/catalog/locations
→ Аналогично: Redis ref:locations TTL 30 мин
→ Возвращает Location[] (только is_active = true)
→ Фронтенд группирует по type (city/district)

[Теги — для расширенных фильтров]
→ GET /api/ref/tags?type={type}
→ Фильтрует по типу и is_visible = true
→ Не кэшируется в Redis (объём небольшой, редко меняется)
→ Возвращает Tag[]
```

---

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

### Таблица валидаций полей

| Поле | Правило | Ошибка пользователю |
|------|---------|-------------------|
| Название группы/предмета (UZ, RU) | 2–100 символов, обязательно | "Название: от 2 до 100 символов" |
| Slug | 2–80 символов, только a-z, 0-9, дефис; уникальный | "Slug: только латиница, цифры и дефис, от 2 до 80 символов" |
| Порядок сортировки | Целое число ≥ 0 | "Введите целое число 0 или больше" |
| Название локации (UZ, RU) | 2–100 символов, обязательно | "Название: от 2 до 100 символов" |
| Широта | Decimal, 37.0–45.6 | "Широта должна быть в диапазоне 37.0–45.6" |
| Долгота | Decimal, 55.9–73.2 | "Долгота должна быть в диапазоне 55.9–73.2" |
| Tag.raw_value | 1–80 символов, slug-формат, уникальный | "Raw value: только строчные буквы, цифры и дефис" |
| Tag.type | Одно из допустимых значений enum | "Выберите тип из списка" |

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

1. **Двухуровневая иерархия Subject:** Каждый Subject должен принадлежать ровно одной SubjectGroup. Группы не имеют родительских групп (максимум 2 уровня).
2. **Slug неизменяем без предупреждения:** Смена slug у предмета/группы требует предупреждения (желтая обводка), так как slug используется в публичных URL.
3. **Soft delete через is_active:** Удаление разрешено только если нет привязок. При наличии привязок — только деактивация.
4. **Автогенерация slug:** При вводе названия (UZ) slug генерируется автоматически путём транслитерации Кириллицы в Латиницу с заменой пробелов на дефисы. Admin может переопределить.
5. **Инвалидация кэша:** При любом изменении SubjectGroup, Subject или Location — Redis-ключи ref:subjects и ref:locations инвалидируются немедленно.
6. **Район обязан иметь родительский город:** Location с type=district должна иметь city_id → Location(type=city).
7. **Минимум одна группа для создания предмета:** Предмет не может быть создан без группы.
8. **Tag.type — расширяемый enum:** Новые типы тегов добавляются через миграцию БД, не через UI.

---

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

> Новые сущности этого модуля. Существующие SellerSubjectLink (Spec 01) и ItemSubjectLink (Spec 02) ссылаются на Subject.subject_id.

### SubjectGroup

| Атрибут | Тип | Описание |
|---------|-----|---------|
| group_id | UUID | PK |
| name_uz | string | 2–100 символов |
| name_ru | string | 2–100 символов |
| name_en | string? | Необязательно |
| slug | string | Уникальный, только a-z/0-9/дефис |
| sort_order | Int | Default: 0 |
| created_at | DateTime | |
| updated_at | DateTime | |

### Subject

| Атрибут | Тип | Описание |
|---------|-----|---------|
| subject_id | UUID | PK |
| group_id | UUID FK | → SubjectGroup |
| name_uz | string | 2–100 символов |
| name_ru | string | 2–100 символов |
| name_en | string? | Необязательно |
| slug | string | Уникальный глобально |
| sort_order | Int | Default: 0 |
| is_active | Boolean | Default: true |
| created_at | DateTime | |
| updated_at | DateTime | |

### Location

| Атрибут | Тип | Описание |
|---------|-----|---------|
| location_id | UUID | PK |
| type | LocationType | city / district |
| name_uz | string | 2–100 символов |
| name_ru | string | 2–100 символов |
| city_id | UUID FK? | → Location (только для type=district) |
| latitude | Decimal(10,7) | Центроид города/района |
| longitude | Decimal(10,7) | Центроид города/района |
| is_active | Boolean | Default: true |
| created_at | DateTime | |
| updated_at | DateTime | |

### Tag

| Атрибут | Тип | Описание |
|---------|-----|---------|
| tag_id | UUID | PK |
| type | TagType | audience_age / format / duration / level / feature / ... |
| raw_value | string | Уникальный slug-формат |
| name_ru | string | Отображаемое название RU |
| name_uz | string | Отображаемое название UZ |
| is_visible | Boolean | Default: true |
| created_at | DateTime | |

---

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

### 6.1 Prisma Schema

```prisma
enum LocationType {
  city
  district
}

enum TagType {
  audience_age
  format
  duration
  level
  feature
  pedagogy
  certification
}

model SubjectGroup {
  group_id   String   @id @default(uuid())
  name_uz    String   @db.VarChar(100)
  name_ru    String   @db.VarChar(100)
  name_en    String?  @db.VarChar(100)
  slug       String   @unique @db.VarChar(80)
  sort_order Int      @default(0)
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  subjects Subject[]
}

model Subject {
  subject_id String   @id @default(uuid())
  group_id   String
  name_uz    String   @db.VarChar(100)
  name_ru    String   @db.VarChar(100)
  name_en    String?  @db.VarChar(100)
  slug       String   @unique @db.VarChar(80)
  sort_order Int      @default(0)
  is_active  Boolean  @default(true)
  created_at DateTime @default(now())
  updated_at DateTime @updatedAt

  group              SubjectGroup        @relation(fields: [group_id], references: [group_id])
  seller_subject_links SellerSubjectLink[]  // → Spec 01
  item_subject_links   ItemSubjectLink[]    // → Spec 02
}

model Location {
  location_id String       @id @default(uuid())
  type        LocationType
  name_uz     String       @db.VarChar(100)
  name_ru     String       @db.VarChar(100)
  city_id     String?      // → self: Location (для type=district)
  latitude    Decimal      @db.Decimal(10, 7)
  longitude   Decimal      @db.Decimal(10, 7)
  is_active   Boolean      @default(true)
  created_at  DateTime     @default(now())
  updated_at  DateTime     @updatedAt

  city      Location?  @relation("CityDistricts", fields: [city_id], references: [location_id])
  districts Location[] @relation("CityDistricts")
}

model Tag {
  tag_id     String   @id @default(uuid())
  type       TagType
  raw_value  String   @unique @db.VarChar(80)
  name_ru    String   @db.VarChar(100)
  name_uz    String   @db.VarChar(100)
  is_visible Boolean  @default(true)
  created_at DateTime @default(now())
}
```

### 6.2 TypeScript DTO

```typescript
// ─── Subject Group ────────────────────────────────────────────────────────

export class CreateSubjectGroupDto {
  @IsString() @MinLength(2) @MaxLength(100)
  name_uz: string

  @IsString() @MinLength(2) @MaxLength(100)
  name_ru: string

  @IsOptional() @IsString() @MaxLength(100)
  name_en?: string

  @Matches(/^[a-z0-9-]{2,80}$/, { message: 'Slug: только a-z, 0-9, дефис, 2–80 символов' })
  slug: string

  @IsOptional() @IsInt() @Min(0)
  sort_order?: number
}

export class UpdateSubjectGroupDto extends PartialType(CreateSubjectGroupDto) {}

// ─── Subject ──────────────────────────────────────────────────────────────

export class CreateSubjectDto {
  @IsUUID()
  group_id: string

  @IsString() @MinLength(2) @MaxLength(100)
  name_uz: string

  @IsString() @MinLength(2) @MaxLength(100)
  name_ru: string

  @IsOptional() @IsString() @MaxLength(100)
  name_en?: string

  @Matches(/^[a-z0-9-]{2,80}$/)
  slug: string

  @IsOptional() @IsInt() @Min(0)
  sort_order?: number

  @IsOptional() @IsBoolean()
  is_active?: boolean
}

export class UpdateSubjectDto extends PartialType(CreateSubjectDto) {}

// ─── Location ─────────────────────────────────────────────────────────────

export class CreateLocationDto {
  @IsEnum(LocationType)
  type: LocationType

  @IsString() @MinLength(2) @MaxLength(100)
  name_uz: string

  @IsString() @MinLength(2) @MaxLength(100)
  name_ru: string

  @IsOptional() @IsUUID()
  city_id?: string  // обязательно если type = district

  @Min(37.0) @Max(45.6)
  latitude: number

  @Min(55.9) @Max(73.2)
  longitude: number

  @IsOptional() @IsBoolean()
  is_active?: boolean
}

export class UpdateLocationDto extends PartialType(CreateLocationDto) {}

// ─── Tag ──────────────────────────────────────────────────────────────────

export class CreateTagDto {
  @IsEnum(TagType)
  type: TagType

  @Matches(/^[a-z0-9-]{1,80}$/)
  raw_value: string

  @IsString() @MaxLength(100)
  name_ru: string

  @IsString() @MaxLength(100)
  name_uz: string

  @IsOptional() @IsBoolean()
  is_visible?: boolean
}

export class UpdateTagDto extends PartialType(CreateTagDto) {}

// ─── Public API Response ──────────────────────────────────────────────────

export interface SubjectGroupPublicDto {
  group_id: string
  name_uz: string
  name_ru: string
  name_en: string | null
  slug: string
  sort_order: number
  subjects: SubjectPublicDto[]
}

export interface SubjectPublicDto {
  subject_id: string
  group_id: string
  name_uz: string
  name_ru: string
  name_en: string | null
  slug: string
  sort_order: number
}

export interface LocationPublicDto {
  location_id: string
  type: LocationType
  name_uz: string
  name_ru: string
  city_id: string | null
  latitude: number
  longitude: number
}

export interface TagPublicDto {
  tag_id: string
  type: TagType
  raw_value: string
  name_ru: string
  name_uz: string
}
```

### 6.3 API Endpoints

```
────────────────────────────────────────────────────────────────
ПУБЛИЧНЫЕ API (без аутентификации, с Redis-кэшем)
────────────────────────────────────────────────────────────────

GET /api/v1/subjects
→ 200: SubjectGroupPublicDto[]  (только активные группы и предметы)
Cache: Redis ref:subjects TTL 30 min

GET /api/v1/catalog/locations
→ 200: LocationPublicDto[]  (только is_active = true)
Cache: Redis ref:locations TTL 30 min

GET /api/ref/tags?type={TagType}
→ 200: TagPublicDto[]  (только is_visible = true; type — опциональный фильтр)
No cache (объём небольшой)

────────────────────────────────────────────────────────────────
ADMIN: SUBJECT GROUPS
────────────────────────────────────────────────────────────────

GET /api/admin/ref/subject-groups
Auth: Bearer (admin: root | marketer)
→ 200: SubjectGroupPublicDto[]  (включая is_active = false)

POST /api/admin/ref/subject-groups
Auth: Bearer (admin: root | marketer)
Body: CreateSubjectGroupDto
→ 201: SubjectGroupPublicDto
→ 409: { error: 'SLUG_TAKEN', message: 'Этот slug уже занят.' }
→ 422: { errors: [{ field: string, message: string }] }

PATCH /api/admin/ref/subject-groups/:group_id
Auth: Bearer (admin: root | marketer)
Body: UpdateSubjectGroupDto
→ 200: SubjectGroupPublicDto
→ 404: { error: 'GROUP_NOT_FOUND' }
→ 409: { error: 'SLUG_TAKEN' }

DELETE /api/admin/ref/subject-groups/:group_id
Auth: Bearer (admin: root | marketer)
→ 204
→ 409: { error: 'GROUP_HAS_SUBJECTS', message: 'Удалите все предметы группы перед удалением группы.' }
→ 404: { error: 'GROUP_NOT_FOUND' }

────────────────────────────────────────────────────────────────
ADMIN: SUBJECTS
────────────────────────────────────────────────────────────────

GET /api/admin/ref/subjects?group_id={uuid}
Auth: Bearer (admin: root | marketer)
→ 200: SubjectPublicDto[]

POST /api/admin/ref/subjects
Auth: Bearer (admin: root | marketer)
Body: CreateSubjectDto
→ 201: SubjectPublicDto
→ 409: { error: 'SLUG_TAKEN' }
→ 422: { errors: [{ field: string, message: string }] }

PATCH /api/admin/ref/subjects/:subject_id
Auth: Bearer (admin: root | marketer)
Body: UpdateSubjectDto
→ 200: SubjectPublicDto
→ 404: { error: 'SUBJECT_NOT_FOUND' }
→ 409: { error: 'SLUG_TAKEN' }

DELETE /api/admin/ref/subjects/:subject_id
Auth: Bearer (admin: root | marketer)
→ 204
→ 409: { error: 'SUBJECT_HAS_BINDINGS', message: string, bindings_count: number }
→ 404: { error: 'SUBJECT_NOT_FOUND' }

────────────────────────────────────────────────────────────────
ADMIN: LOCATIONS
────────────────────────────────────────────────────────────────

GET /api/admin/ref/locations
Auth: Bearer (admin: root | marketer)
→ 200: LocationPublicDto[]  (включая is_active = false)

POST /api/admin/ref/locations
Auth: Bearer (admin: root | marketer)
Body: CreateLocationDto
→ 201: LocationPublicDto
→ 400: { error: 'INVALID_COORDINATES', message: string }
→ 400: { error: 'CITY_REQUIRED_FOR_DISTRICT', message: 'Для района необходимо указать city_id.' }
→ 409: { error: 'NAME_TAKEN', message: 'Локация с таким названием уже существует.' }
→ 422: { errors: [{ field: string, message: string }] }

PATCH /api/admin/ref/locations/:location_id
Auth: Bearer (admin: root | marketer)
Body: UpdateLocationDto
→ 200: LocationPublicDto
→ 404: { error: 'LOCATION_NOT_FOUND' }

DELETE /api/admin/ref/locations/:location_id
Auth: Bearer (admin: root | marketer)
→ 204
→ 409: { error: 'LOCATION_HAS_BINDINGS', message: string }
→ 404: { error: 'LOCATION_NOT_FOUND' }

────────────────────────────────────────────────────────────────
ADMIN: TAGS
────────────────────────────────────────────────────────────────

GET /api/admin/ref/tags?type={TagType}
Auth: Bearer (admin: root | marketer)
→ 200: TagPublicDto[]  (включая is_visible = false)

POST /api/admin/ref/tags
Auth: Bearer (admin: root | marketer)
Body: CreateTagDto
→ 201: TagPublicDto
→ 409: { error: 'RAW_VALUE_TAKEN' }
→ 422: { errors: [{ field: string, message: string }] }

PATCH /api/admin/ref/tags/:tag_id
Auth: Bearer (admin: root | marketer)
Body: UpdateTagDto
→ 200: TagPublicDto
→ 404: { error: 'TAG_NOT_FOUND' }

DELETE /api/admin/ref/tags/:tag_id
Auth: Bearer (admin: root | marketer)
→ 204
→ 409: { error: 'TAG_IN_USE', message: string, items_count: number }
→ 404: { error: 'TAG_NOT_FOUND' }
```

---

## 7. Edge Cases

| Сценарий | Поведение |
|----------|----------|
| Redis недоступен при GET /api/v1/subjects | Запрос идёт напрямую к PostgreSQL; ответ возвращается корректно, без кэширования |
| Одновременное обновление справочника двумя Admin | Последний PATCH побеждает (last-write-wins); конфликт оптимистичной блокировки не реализован в MVP |
| Slug субъекта содержит специальные символы (транслитерация UZ) | Символы «ғ, қ, ҳ, ӯ, ъ» транслитерируются согласно таблице: ғ→g, қ→q, ҳ→h, ӯ→u, ъ→убирается |
| Попытка создать район без города (city_id = null) | 400 CITY_REQUIRED_FOR_DISTRICT |
| Попытка удалить группу у которой есть предметы | 409 GROUP_HAS_SUBJECTS; сначала удалите или переместите все предметы группы |
| Публичный API вызван пока идёт инвалидация кэша (race condition) | Возможна задержка до 100 мс; в худшем случае — повторный запрос к БД |
| Предмет деактивирован, но привязан к активным айтемам | Предмет скрыт из форм поиска и создания, но остаётся в данных существующих айтемов |
| GET /api/v1/subjects с locale-заголовком | Сервер возвращает все 3 языка; локализацию выбирает клиент |
| Admin пытается создать субъект в несуществующей группе | 422 с ошибкой group_id: "Группа не найдена" |

---

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

| Тема | Статус | Примечание |
|------|--------|-----------|
| Импорт справочников из CSV/Excel | Исключено из MVP | Все записи создаются вручную через UI. Bulk-import — в v1.0 |
| История изменений справочников (audit log) | TBD | Нужен ли лог "кто и когда изменил"? Механизм не определён |
| Перемещение предмета между группами | TBD | PATCH subject.group_id разрешён технически, но нет UI для drag-and-drop. Реализовать в v1.0 |
| Slug-редиректы при смене slug | Вне скоупа MVP | 301-редиректы для старых slug не реализуются в MVP. Задокументировать риск |
| Локализованный поиск по справочнику (fuzzy matching) | Вне скоупа MVP | Поиск по exact match через LIKE. Full-text — в v1.0 |
| Иерархия более 2 уровней (подгруппы предметов) | Сознательно исключено | Максимальная глубина: группа → предмет. Более сложная иерархия не планируется |
| Tag.type расширение через UI | Исключено | Новые типы тегов — только через миграцию БД и деплой |
| Bulk-деактивация предметов | Исключено из MVP | Только по одному через UI |

---

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

| Модуль | Связь |
|--------|-------|
| **Spec 01** (Seller Onboarding) | SellerSubjectLink.subject_id → Subject.subject_id; GET /api/v1/subjects используется в дропдауне онбординга |
| **Spec 02** (Item Management) | ItemSubjectLink.subject_id → Subject.subject_id; Location используется в Item |
| **Spec 05** (Catalog) | Фильтры каталога по subject_id и location_id; GET /api/v1/subjects и /api/v1/catalog/locations |
| **Spec 16** (CPL Billing) | Нет прямой зависимости |
| **Spec 17** (SEO & i18n) | Subject.slug и Location генерируют URL-компоненты; transliteration-логика slug определяется здесь |
| **Redis** | Кэш ref:subjects и ref:locations TTL 30 мин |
| **Nominatim** | Geocoding для автоопределения координат при создании локации |
