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 символов" |
| 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 | "Выберите тип из списка" |
Бизнес-правила
- Двухуровневая иерархия Subject: Каждый Subject должен принадлежать ровно одной SubjectGroup. Группы не имеют родительских групп (максимум 2 уровня).
- Slug неизменяем без предупреждения: Смена slug у предмета/группы требует предупреждения (желтая обводка), так как slug используется в публичных URL.
- Soft delete через is_active: Удаление разрешено только если нет привязок. При наличии привязок — только деактивация.
- Автогенерация slug: При вводе названия (UZ) slug генерируется автоматически путём транслитерации Кириллицы в Латиницу с заменой пробелов на дефисы. Admin может переопределить.
- Инвалидация кэша: При любом изменении SubjectGroup, Subject или Location — Redis-ключи ref:subjects и ref:locations инвалидируются немедленно.
- Район обязан иметь родительский город: Location с type=district должна иметь city_id → Location(type=city).
- Минимум одна группа для создания предмета: Предмет не может быть создан без группы.
- 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
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 | Нужен ли лог "кто и когда изменил"? Механизм не определён |
| Перемещение предмета между группами | 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 для автоопределения координат при создании локации |