ADR-0055: Categories namespace + ETIM-7 backbone
Status: accepted Date: 2026-05-13 Deciders: belkanov, agent-claude
Контекст
К концу подцикла 1 «Catalog Categories Backbone» (HEAD 9b48f6fe)
supplier_offers.category_id заполняется resolver-ом на каждом тике для
четырёх поставщиков (ETM/IEK/Systeme/Russvet). Каждая поставщическая
иерархия пишется в свою подветку таблицы categories. DKC параллельно
заполняет taxonomy_classes (ETIM-7), но связь с categories не
устанавливается — это два изолированных дерева.
Бизнес-постановка: оператор каталога видит единое каноническое дерево
категорий в /canonical. Поставщические деревья — внутренняя машинерия
ingestion-pipeline, оператор их не настраивает руками. Каноническое
дерево должен быть:
- Свободно для ручных правок: rename, новые узлы, переустройство, объединение или split. Никакой жёсткой привязки к структуре внешнего стандарта.
- С быстрым стартом: на 5k+ узлов руками не нарисовать; нужен bootstrap из существующего стандарта.
Обсуждались три подхода:
- Отдельная таблица
canonical_categories, параллельнаяcategories. Минус — двукратное дублирование схемы (parent_id, slug, alias_slugs, locale, indexes), двойной admin handler, двойной Resolver code-path. - Использовать
taxonomy_classesнапрямую как каноническое дерево. Минус — нельзя ручные правки: переименование класса нарушает контракт federated taxonomy (ADR-0050), таблица read-only от importer’а. - Один
categoriesс namespace-колонкой. Re-use инфраструктуры, но требует discipline на запросах (filter by tree_type) и двухтабличного mapping черезcategory_external_links.
Решение
Идём по подходу 3. Конкретно:
Namespace в categories
- Добавляем
categories.tree_type TEXT NOT NULL DEFAULT 'supplier_native'с CHECK в {supplier_native,canonical}. Существующие строки (из подцикла 1) автоматически попадают вsupplier_native. - Resolver из подцикла 1 продолжает писать в
supplier_nativeбез изменений (DEFAULT срабатывает). - Bootstrap-job создаёт строки в
canonicalnamespace.
Mapping через category_external_links
Новая таблица зеркалит canonical_external_identities, но для
категорий, а не продуктов:
category_external_links (
id, category_id, standard_code, external_code,
source CHECK (source IN ('manual', 'auto_supplier', 'auto_llm', 'auto_inference')),
decision_meta JSONB,
created_by, created_at, updated_at,
UNIQUE (category_id, standard_code),
UNIQUE (standard_code, external_code)
)
Двойной UNIQUE: один canonical-узел не имеет двух связок с одним
стандартом; один внешний код не привязан к двум canonical-узлам
одновременно. Это сильное ограничение, но реалистичное:
bootstrap-инвариант — ровно одна (standard, external_code) → canonical_category.
Защита ручных правок
Двухуровневая, симметричная подходу ADR-0050:
- Уровень категории:
categories.origin = 'manual'означает «строка создана/правлена админом». Bootstrap не перезаписываетnameиparent_idдля таких строк; только обновляетupdated_at. - Уровень связи:
category_external_links.source = 'manual'означает «админ привязал ETIM-класс к ДРУГОЙ canonical-категории вручную». Bootstrap не трогает такую связь и не создаёт дубликат.
Эти два уровня независимы: можно иметь
origin=etim-7-auto + link.source=manual (категория из bootstrap,
но связь переустановлена админом) и
origin=manual + link.source=auto_supplier (категория переименована
админом, связь автоматическая, обновляется на следующем bootstrap).
Bootstrap-flow
backend/internal/core/catalog/categories/app/etim_bootstrapper.go:
- Создать (если нет) корневой canonical-узел
slug=etim-7, tree_type=canonical, origin=etim-7-auto, parent_id=NULL. - Загрузить все классы
taxonomy_classesстандартаETIM-7. - Топологически отсортировать по
parent_external(root first). - Для каждого класса:
- Найти существующую связь
(standard=ETIM-7, external_code). Еслиsource='manual'— skip. - Найти существующую категорию по slug
etim7-<external_code>. Еслиorigin='manual'— bump только updated_at; иначе — full upsert name + parent_id (parent_id уже резолвится из вставленного выше узла). - Upsert связь с
source='auto_supplier'.
- Найти существующую связь
- Эмитировать метрики и report.
Когда запускается bootstrap
В подцикле 2 — только через CLI cmd/category-bootstrap. UI-инициируемый
bootstrap откладывается на подцикл 3 (вместе с tree management UI).
Bootstrap идемпотентен, безопасен для повторного запуска (annual
ETIM-update use case).
Альтернативы (отвергнуто)
- Отдельная таблица: см. контекст #1. Удваивает support cost.
- Triggers вместо bootstrap-job: на каждый INSERT в
taxonomy_classesсоздавать row вcategories. Минус — теряем топологический порядок, теряем контроль над--dry-run, лишний side-effect в чистый ingestion. Bootstrap-job явный и видимый.
Последствия
Положительные
- Resolver из подцикла 1 не меняется (DEFAULT срабатывает).
- Admin handler GET
/api/v1/admin/catalog/categoriesполучает опциональныйtree_typeфильтр; backward-compatible. - В будущем легко добавить ещё один namespace (например,
customer_privateдля клиент-специфичных деревьев).
Отрицательные
- Запросы по дереву должны явно фильтровать
tree_type— иначе смешивают supplier-native и canonical. РасширениеListQueryобязательно. - Расхождение конвенций:
canonical_external_identitiesиспользуетauto_supplierдля DKC, аcategory_external_links— для самого bootstrap-job’а. Семантика в этом контексте одинаковая («автоматически из источника»), conflate приемлем. - Двойной UNIQUE на
category_external_linksстрогий: если в будущем потребуется аналог-маппинг (один ETIM-класс на две canonical category), constraint нужно ослабить. На текущем horizon не предвидится.
Open items
- Подцикл 2b (отдельный план): Resolver делает lookup ETIM-class →
canonical category при обработке DKC offer-а и пишет
supplier_offers.category_idсразу в canonical-узел. Сейчас supplier_offers.category_id продолжает указывать на supplier_native —/canonicalфильтр по canonical-категории не найдёт DKC-офферы. 2b закрывает этот gap. - Multi-language: ETIM публикует имена на многих языках;
categoriesлокали =ruпо умолчанию. Bootstrap копируетnameкак есть, multi-language отнесён в будущий подцикл. - ETIM deactivation: класс может стать
is_active=false. Bootstrap пока не отражает это вcategories(is_activeколонки нет). Если понадобится — отдельная миграция.