Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-15-reference-data.md

MVP Spec 15 — Reference Data Management

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 15 — Reference Data Management

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

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

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 символов"
Slug2–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_value1–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_idUUIDPK
name_uzstring2–100 символов
name_rustring2–100 символов
name_enstring?Необязательно
slugstringУникальный, только a-z/0-9/дефис
sort_orderIntDefault: 0
created_atDateTime
updated_atDateTime

Subject

АтрибутТипОписание
subject_idUUIDPK
group_idUUID FK→ SubjectGroup
name_uzstring2–100 символов
name_rustring2–100 символов
name_enstring?Необязательно
slugstringУникальный глобально
sort_orderIntDefault: 0
is_activeBooleanDefault: true
created_atDateTime
updated_atDateTime

Location

АтрибутТипОписание
location_idUUIDPK
typeLocationTypecity / district
name_uzstring2–100 символов
name_rustring2–100 символов
city_idUUID FK?→ Location (только для type=district)
latitudeDecimal(10,7)Центроид города/района
longitudeDecimal(10,7)Центроид города/района
is_activeBooleanDefault: true
created_atDateTime
updated_atDateTime

Tag

АтрибутТипОписание
tag_idUUIDPK
typeTagTypeaudience_age / format / duration / level / feature / ...
raw_valuestringУникальный slug-формат
name_rustringОтображаемое название RU
name_uzstringОтображаемое название UZ
is_visibleBooleanDefault: true
created_atDateTime

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

6.1 Prisma Schema

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

// ─── 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Нужен ли лог "кто и когда изменил"? Механизм не определён
Перемещение предмета между группамиTBDPATCH subject.group_id разрешён технически, но нет UI для drag-and-drop. Реализовать в v1.0
Slug-редиректы при смене slugВне скоупа MVP301-редиректы для старых 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 мин
NominatimGeocoding для автоопределения координат при создании локации