# Спецификация данных материалов и наполнения (canonical catalog v1.0) **Для кого:** ИИ-агент, который собирает/готовит данные о материалах, наполнении и работах и загружает их в базу модуля расчёта сметы (renovation-estimator), либо разрабатывает адаптер под новый источник (прайс, API, парсер). **Что описывает:** канонический формат позиции каталога, плоский CSV-вариант, правила категорий и атрибутов, идемпотентность загрузки. **Статус:** v1.0, схема БД согласована 2026-08-09. Расчёт **работ** в смете отложен на финальный этап проекта, но данные о работах собираются уже сейчас по этой же спецификации — они лягут в каталог и включатся в расчёт позже. ## 1. Принцип Источники бывают любые: Excel/CSV-прайсы, API поставщиков, парсинг сайтов, ручной ввод. Модулю всё равно — он принимает **один канонический формат**. Задача агента-поставщика данных: привести свой источник к канонике (JSON §3 или CSV §4). Загрузка идёт через карантин (staging): ошибочные строки отбраковываются поштучно и не блокируют остальные; повторная загрузка того же файла ничего не дублирует. ## 2. Категории, единицы, атрибуты ### 2.1. Дерево категорий Категория задаётся кодом-слагом с точками: `flooring.laminate`, `furniture.sofa`, `work.painting`. Тип позиции определяется категорией: **material** (расходные материалы), **furnishing** (наполнение: мебель, сантехника, свет, техника), **work** (работы). Стартовое дерево (расширяется по мере поступления данных): ``` flooring (material) напольные покрытия .laminate .underlay .skirting .tile finishes (material) отделочные материалы .paint .primer .plaster .wallpaper furniture (furnishing) мебель .sofa .bed .table .chair .storage plumbing (furnishing) сантехника .toilet .sink .bath .shower .faucet lighting (furnishing) свет .ceiling .wall .floor_lamp appliances (furnishing) техника work (work) работы .flooring .painting .plumbing .electrical .demolition ``` Нужной категории нет → **не выдумывать код самостоятельно**: запросить добавление (категория + шаблон атрибутов заводятся данными за минуты, структура базы не меняется). Позиция с неизвестным `category_code` при загрузке отбраковывается со статусом `error`. ### 2.2. Единицы измерения (`base_unit`, `unit`) `m2` (кв. метр) · `m` (пог. метр) · `pcs` (штука) · `l` (литр) · `kg` (килограмм) · `pack` (упаковка) · `set` (комплект). `base_unit` позиции — то, в чём считается объём в смете (ламинат — `m2`, плинтус — `m`, диван — `pcs`, краска — `l`). Цена может быть в другой единице (за упаковку) — см. §5. ### 2.3. Атрибуты (`attrs`) У каждой категории — свой шаблон допустимых атрибутов (код, тип, обязательность). Значения передаются плоским объектом `attrs`; ключи с единицей в суффиксе: `width_mm`, `power_w`, `volume_l`. Типы: number, string, bool, enum (фиксированный список значений). Действующие шаблоны (фрагмент; актуальный список будет на API модуля `GET /api/v1/catalog/categories`): | Категория | Атрибут | Тип | Обяз. | |---|---|---|---| | flooring.laminate | `thickness_mm` | number | нет | | flooring.laminate | `class` | number | **да** | | flooring.laminate | `color` | string | нет | | finishes.paint | `color` | string | нет | | finishes.paint | `base` | enum: `water` \| `alkyd` | нет | | furniture.sofa | `width_mm` | number | **да** | | furniture.sofa | `depth_mm`, `seats` | number | нет | | furniture.sofa | `upholstery` | enum: `рогожка` \| `велюр` \| `экокожа` | нет | Правила: неизвестный атрибут категории → warning (значение сохраняется в сыром снапшоте, в подборе не участвует); отсутствие обязательного → `error`, строка отбраковывается. **По атрибутам работает автоподбор наполнения** («диван до 2.4 м, от 3 мест») — чем полнее `attrs`, тем полезнее позиция. ## 3. Канонический формат позиции (JSON) ```json { "external_key": "LM-90064221", "category_code": "flooring.laminate", "name": "Ламинат Дуб Натур 33 класс 8 мм", "description": null, "base_unit": "m2", "package": { "qty": 2.13, "unit": "m2" }, "attrs": { "thickness_mm": 8, "class": 33, "color": "дуб натур" }, "prices": [ { "supplier": "demo_a", "price": 1290.00, "currency": "RUB", "unit": "m2", "price_type": "retail" } ], "image_url": null, "is_active": true } ``` | Поле | Тип | Обяз. | Описание | |---|---|---|---| | `external_key` | string ≤200 | **да** | Стабильный ключ позиции в источнике: артикул, SKU, URL. По паре (источник, ключ) работает обновление без дублей. Ключ обязан быть стабильным между выгрузками | | `category_code` | string | **да** | Код категории из §2.1 | | `name` | string ≤300 | **да** | Торговое название | | `description` | string | нет | Развёрнутое описание | | `base_unit` | string | **да** | Из §2.2 | | `package` | object | жел. для материалов | `qty` — сколько базовых единиц в упаковке (пачка 2.13 м², ведро 9 л, хлыст 2.4 м, до 99 999 999.9999), `unit` — единица `qty`. По ней смета округляет закупку вверх до целых упаковок | | `attrs` | object | по шаблону | §2.3 | | `prices` | array | жел. | §5. Пустой массив допустим: позиция попадёт в смету с пометкой «нет цены», расчёт не падает | | `image_url` | string ≤1000 | нет | Прямая ссылка на изображение | | `is_active` | bool | нет, деф. true | `false` — позиция снята с продажи (остаётся в старых сметах, из подбора исключается) | Загрузка: `POST /api/v1/import/batches` — массив таких объектов (после деплоя модуля; до него — файлы в согласованное место). Один батч = одна выгрузка одного источника. ### 3.1. Примеры по типам Наполнение (мебель): ```json { "external_key": "SOFA-OSLO-3", "category_code": "furniture.sofa", "name": "Диван 3-местный «Осло», рогожка", "base_unit": "pcs", "attrs": { "width_mm": 2200, "depth_mm": 950, "seats": 3, "upholstery": "рогожка" }, "prices": [ { "supplier": "demo_b", "price": 68900.00, "currency": "RUB", "unit": "pcs", "price_type": "retail" } ] } ``` Работа (собираем уже сейчас, в смету включится на финальном этапе): ```json { "external_key": "WRK-LAM-001", "category_code": "work.flooring", "name": "Укладка ламината с подложкой", "base_unit": "m2", "prices": [ { "supplier": "crew_ivanov", "price": 550.00, "currency": "RUB", "unit": "m2", "price_type": "estimate" } ] } ``` У работ обычно нет `package` и `attrs`; `supplier` — бригада/подрядчик; `price_type` чаще `estimate`. ## 4. Плоский CSV-вариант Для прайсов удобнее таблица. Правила: UTF-8, разделитель `;`, первая строка — заголовки, десятичный разделитель — точка (запятая допустима, будет приведена). Колонки `attr:<код>` уходят в `attrs`. ```csv external_key;category_code;name;base_unit;package_qty;package_unit;attr:thickness_mm;attr:class;attr:color;price;currency;price_unit;price_type;supplier LM-90064221;flooring.laminate;Ламинат Дуб Натур 33 класс 8 мм;m2;2.13;m2;8;33;дуб натур;1290.00;RUB;m2;retail;demo_a LM-90064221;flooring.laminate;Ламинат Дуб Натур 33 класс 8 мм;m2;2.13;m2;8;33;дуб натур;1245.00;RUB;m2;retail;demo_b SKIRT-MDF-80;flooring.skirting;Плинтус МДФ 80 мм белый;m;2.4;m;;;;390.00;RUB;m;retail;demo_a ``` Одна строка = одна цена. Несколько цен на позицию (как ламинат выше) — повторить строку с тем же `external_key` и другим `supplier`/`price_type`: карточка позиции возьмётся из первой строки, цены соберутся со всех. ## 5. Цены | Поле | Обяз. | Описание | |---|---|---| | `price` | да | Число, 2 знака после точки, до 9 999 999 999.99 | | `currency` | нет, деф. `RUB` | ISO-4217 (3 символа) | | `unit` | да | За какую единицу цена: базовая (`m2`) или упаковка (`pack` — тогда у позиции должен быть `package`) | | `supplier` | жел. | Код поставщика, ≤80 символов (слаг латиницей: `demo_a`, `leroy`). Новый код = новый поставщик, заводится автоматически; человекочитаемое имя сообщите при первой поставке. Запрещённых кодов нет | | `price_type` | нет, деф. `retail` | `retail` (розница) \| `trade` (опт) \| `estimate` (оценка без конкретного поставщика) | Историю цен ведёт модуль: повторная загрузка с новой ценой не затирает старую, а добавляет запись и переключает актуальную. Присылать историю не нужно — только текущие цены. ## 6. Идемпотентность и жизненный цикл - Ключ сопоставления: **(код источника, `external_key`)**. Код источника присваивается при регистрации (например `leroy_csv`); один источник — один код навсегда. - Повторная загрузка того же файла → 0 изменений. Изменилась цена → новая запись цены. Изменились ключевые поля карточки (категория, base_unit) → строка получает статус `conflict` и требует подтверждения, автоматом не применяется. - Позиция пропала из выгрузки → ничего не происходит автоматически (не деактивируется). Снятие с продажи — явно: `is_active: false`. - Каждый батч возвращает отчёт. Поля: | Поле | Что считает | |---|---| | `rows` | всего строк в батче | | `created` | заведено новых позиций | | `updated` | обновлена карточка (возможно, вместе с ценой) | | `price_only` | карточка та же, изменилась только цена | | `unchanged` | не изменилось ничего | | `price_changes` | сколько записей цен добавлено в историю | | `errors` | строк отбраковано с ошибкой | | `conflicts` | строк требует подтверждения (смена ключевых полей) | Категории строк не пересекаются: `created + updated + price_only + unchanged + errors + conflicts = rows`. `price_changes` считает записи цен и в эту сумму не входит. Построчные ошибки — в `GET /api/v1/import/batches/{id}` (поля `row_num`, `external_key`, `match_status`, `error`). - Есть dry-run: та же загрузка без применения, только отчёт. - Цена без указанного `supplier` допустима — в выдаче API она возвращается с `"supplier": null`. ## 6.1. Авторизация - Все вызовы API требуют заголовок **`X-API-Key`**; публичны только `GET /api/v1/health` и `GET /api/v1/schema/layout`. - Поставщику данных выдаётся ключ роли `data`: он открывает импорт (`/api/v1/import/*`), ручное создание позиций (`POST /api/v1/catalog/items`) и чтение каталога. Проекты, планировки и сметы этой ролью **не** доступны — там будет `403`. - Без заголовка — `401`. Ключ передаётся только заголовком (в query-параметре не работает), хранится в переменных окружения и в репозиторий не попадает. ## 7. Чек-лист качества для агента-поставщика данных 1. `external_key` стабилен между выгрузками (не порядковый номер строки!). 2. Категория — из действующего дерева; новые категории запрошены заранее. 3. `base_unit` соответствует тому, как позиция считается в смете (ламинат м², не пачки; краска литры, не вёдра — вёдра описываются через `package`). 4. `package` заполнен для всего, что продаётся упаковками/хлыстами/вёдрами — без него смета не сможет округлить закупку до целых упаковок. 5. Обязательные `attrs` категории заполнены; размеры в мм, мощность в Вт, объём в л. 6. Цена с единицей: «1290 за м²» или «5490 за ведро (`unit: pack`)» — но не «5490» без контекста. 7. Кириллица в enum-значениях — точно как в шаблоне (`рогожка`, не `Рогожка`). 8. Один батч — один источник; не смешивать прайсы разных поставщиков в одном файле, если у них разные `external_key`-пространства.