# Спецификация данных планировки (layout contract v1.0) **Для кого:** ИИ-агент, разрабатывающий генератор планировок. **Что описывает:** JSON, который генератор передаёт модулю расчёта сметы (renovation-estimator). **Статус:** контракт v1.0, схема БД модуля согласована 2026-08-09. Формат версионируется полем `schema_version`; несовместимые изменения = новая мажорная версия. **Правка 2026-08-10 (QA этапа 5):** появилась авторизация — см. §5.1, теперь для всех вызовов кроме двух публичных нужен заголовок `X-API-Key`. Исправлен §3.4: приём планировки с этого этапа сверяет и **тип** условия подбора (раньше спека честно писала, что не сверяет, — теперь это неверно, и «принято приёмом, отклонено каталогом» больше не бывает). **Правка 2026-08-10 (QA этапа 4.2):** уточнён §3.4 — «шаблон категории» в условиях подбора означает всё поддерево (атрибуты предков И потомков), и это правило теперь одинаково у `GET /catalog/items`, у приёма планировки и у расчёта: приём отклонял условие на атрибут подкатегории, которое эндпоинт каталога одобрял. Явно записано единственное оставшееся отличие приёма от эндпоинта — приём не сверяет тип условия. **Правка 2026-08-10 (QA этапа 4.1):** документ снова сверен с реализацией. Убрано обещание сверять присланные `area_m2` (такого поля в контракте нет — §1), уточнено, где именно проверяются условия `selection.attrs` и когда появляется статус подбора (§3.4), описаны обе формы отказа 422 и снята ссылка на копию JSON Schema в репозитории (§5). Поведение модуля не менялось, кроме одного: условие подбора, из которого нельзя построить фильтр, больше не выбрасывается молча — объект считается неподобранным с причиной в `problem`. **Правка 2026-08-10 (QA этапа 3):** документ приведён в соответствие с реализацией — добавлены §1.1 «Пределы и строгость» (границы значений, которые модуль и раньше проверял, но спека о них молчала), уточнены правила согласованности §3.2, привязка `system_code` к поверхности §3.3 и единая форма отказа §5. Поля не добавлялись и не переименовывались; единственное ужесточение — нижняя граница `qty` (0.001). ## 1. Общие соглашения - Формат: JSON, UTF-8. - Все линейные размеры и координаты — **миллиметры, целые числа**. Никаких метров и дробей: `2700`, а не `2.7`. - Система координат: двумерный план, ось X вправо, ось Y вверх (или вниз — важна лишь согласованность внутри одной планировки). Начало координат произвольное, у каждой комнаты — своё локальное или общее, на расчёт не влияет: модуль работает с геометрией каждой комнаты автономно. - Углы поворота (`rotation`) — градусы, целые, против часовой стрелки, 0–359. - Площади и периметры модуль **всегда считает сам** из геометрии. Своих значений генератор не присылает: поля `area_m2` в контракте v1.0 нет, и попытка его передать даёт 422 как любое неизвестное поле (§1.1). Сверка присланных площадей с расчётными — кандидат в v1.1, сообщите, если нужна. - Планировка иммутабельна: каждая передача = новая версия на стороне модуля. Правка планировки — это повторная отправка целиком, не патч. ### 1.1. Пределы и строгость приёма Контракт **строгий**: неизвестное поле на любом уровне — ошибка 422, а не молчаливое игнорирование (опечатка `heigth` не должна тихо съедаться). Если нужно новое поле — это минорная версия контракта, см. §6. Жёсткие границы значений (нарушение → 422): | Что | Диапазон | |---|---| | `height` комнаты, `width`/`height` проёма | 1 … 100 000 мм | | `sill_height`, `offset` | 0 … 100 000 мм | | координаты `polygon`, `position.x/y` | −10 000 000 … 10 000 000 мм | | вершин в `polygon` | 3 … 200 | | комнат в `rooms` | 1 … 500 | | `qty` | 0.001 … 10 000 (хранится с точностью 0.001) | | `rotation` | 0 … 359 | | длина строк: `id` / `name` / `room_type` / `system_code` / `category_code` / `generator_version` | 120 / 200 / 80 / 120 / 120 / 40 символов | | площадь пола одной комнаты | ≤ 9 999 999.999 м² | ## 2. Структура верхнего уровня ```json { "schema_version": "1.0", "layout_id": "gen-7f3a-0042", "generator_version": "0.3.1", "units": "mm", "rooms": [ ... ] } ``` | Поле | Тип | Обяз. | Описание | |---|---|---|---| | `schema_version` | string | да | Версия контракта, сейчас `"1.0"` | | `layout_id` | string | да | Уникальный id планировки в генераторе. Повторная отправка того же id = новая версия той же планировки | | `generator_version` | string | нет | Версия генератора — для отладки | | `units` | string | да | Всегда `"mm"` в v1.0 | | `rooms` | array | да, ≥1 | Комнаты | ## 3. Комната (`rooms[]`) ```json { "id": "room-1", "name": "Гостиная", "room_type": "living_room", "height": 2700, "polygon": [[0,0],[5200,0],[5200,3800],[0,3800]], "walls": [ ... ], "finishes": { ... }, "placed_items": [ ... ] } ``` | Поле | Тип | Обяз. | Описание | |---|---|---|---| | `id` | string | да | Уникален в рамках планировки | | `name` | string | да | Человекочитаемое имя | | `room_type` | string | да | Из словаря §3.1 (иное значение допустимо, но дефолтная отделка для него не подберётся) | | `height` | int, мм | да | Высота потолка. Наклонные потолки и разноуровневость — вне v1.0 | | `polygon` | [[x,y]…] | да, ≥3 вершин | Контур пола по часовой или против — любым обходом, без самопересечений, без повторения первой точки в конце | | `walls` | array | да | По одной записи на каждое ребро полигона, см. §3.2 | | `finishes` | object | нет | Отделка поверхностей, см. §3.3. Не указана → модуль берёт дефолт по `room_type` | | `placed_items` | array | нет | Наполнение, см. §3.4 | ### 3.1. Словарь `room_type` `living_room`, `bedroom`, `kitchen`, `bathroom` (ванная/санузел совмещённый), `wc`, `hallway` (прихожая), `corridor`, `balcony`, `storage` (кладовая), `office`, `other`. ### 3.2. Стены и проёмы (`walls[]`) Правило соответствия: **`walls[i]` — это ребро полигона от вершины `polygon[i]` к `polygon[(i+1) % n]`**. Записей в `walls` ровно столько, сколько вершин в полигоне. Стена без проёмов — запись с пустым `openings` (или без него). ```json { "index": 0, "openings": [ { "id": "op-1", "kind": "window", "width": 1500, "height": 1450, "sill_height": 900 }, { "id": "op-2", "kind": "door", "width": 900, "height": 2100, "linked_room_id": "room-2" } ] } ``` | Поле проёма | Тип | Обяз. | Описание | |---|---|---|---| | `id` | string | да | Уникален в рамках планировки | | `kind` | string | да | `door` \| `window` \| `passage` (проём без двери) | | `width`, `height` | int, мм | да | Габариты проёма | | `sill_height` | int, мм | для окон | Высота подоконника от пола | | `offset` | int, мм | нет | Отступ левого края проёма от начала стены (вершины `polygon[i]`). Для расчёта площадей не нужен, полезен для визуализации | | `linked_room_id` | string | для межкомнатных `door`/`passage` | id смежной комнаты. **Критично**: по нему модуль понимает, что дверь одна, хотя проём описан в стенах обеих комнат | Правила согласованности (модуль проверяет, нарушение → error): - сумма ширин проёмов стены ≤ длины стены (длину модуль считает из полигона; допуска на «лишний миллиметр» нет, у наклонных стен округляйте ширину вниз); - `height` проёма ≤ высоты комнаты; для окон `sill_height + height` ≤ высоты комнаты; - у парных межкомнатных проёмов `linked_room_id` указывают **обе** комнаты, ссылаясь друг на друга; габариты пары должны совпадать. Проблема пары сообщается один раз, со стороны комнаты с меньшим `id`; - полигон простой: без самопересечений, без повторяющихся вершин (в том числе несоседних) и без наложения рёбер друг на друга. Смежные стены двух комнат каждая комната описывает **со своей стороны полностью** — модуль не дедуплицирует стены (отделка у каждой стороны своя). Дедуплицируются только объекты-двери через `linked_room_id`. Как модуль это считает: площадь стены = длина × высота − Σ площадей проёмов; периметр плинтуса = периметр пола − Σ ширин `door`/`passage`; потолок = площадь пола. ### 3.3. Отделка (`finishes`) Все три ключа опциональны; отсутствие ключа = «модуль возьмёт дефолт по типу комнаты». ```json { "floor": { "system_code": "floor_laminate", "item_ref": { "catalog_item_id": 1017 } }, "walls": { "system_code": "wall_paint", "params": { "color": "RAL9010" } }, "ceiling": { "system_code": "ceiling_stretch" } } ``` | Поле | Обяз. | Описание | |---|---|---| | `system_code` | да (внутри ключа) | Код системы отделки из справочника модуля. Актуальный список отдаёт API модуля (`GET /api/v1/catalog/finish-systems`, после деплоя); стартовый набор: `floor_laminate`, `wall_paint`. Рецепт привязан к своей поверхности: `wall_paint` под ключом `floor` — ошибка 422, а не «как-нибудь посчитаем». Снятый с производства рецепт (`is_active: false`) назвать тоже нельзя — 422: модуль не подменяет его дефолтом молча. Если рецепт не назван и снят с производства **дефолт** по типу комнаты — поверхность остаётся без отделки, и это попадёт в смету пометкой, а не отказом | | `item_ref.catalog_item_id` | нет | Конкретная позиция каталога вместо подбора (конкретный ламинат). Применяется к компоненту рецепта, категория которого совпадает с категорией позиции. Не указано → модуль подберёт сам (самую дешёвую по политике цен). Если позиция не найдена или её категории в рецепте нет, расчёт идёт обычным подбором, а в смету добавляется пометка `ITEM_REF_IGNORED` — молча просьба не теряется | | `params` | нет | Уточнения (цвет и т.п.) — прокидываются в смету справочно | В v1.0 отделка задаётся на комнату (все стены одинаково). Отделка отдельных стен — кандидат в v1.1, сообщите, если нужна раньше. ### 3.4. Наполнение (`placed_items[]`) Каждый объект — **либо** конкретная позиция каталога, **либо** параметры подбора. Ровно один из двух способов. ```json { "id": "pl-1", "catalog_item_id": 2044, "qty": 1, "position": { "x": 1200, "y": 300, "rotation": 90 } } ``` ```json { "id": "pl-2", "selection": { "category_code": "furniture.sofa", "attrs": { "width_mm": { "max": 2400 }, "seats": { "min": 3 }, "upholstery": "рогожка" } }, "qty": 1, "position": { "x": 3000, "y": 3400, "rotation": 0 } } ``` | Поле | Обяз. | Описание | |---|---|---| | `id` | да | Уникален в рамках планировки | | `catalog_item_id` | либо это | id позиции каталога модуля. Генератор получает каталог через `GET /api/v1/catalog/items?category=…` | | `selection` | либо это | `category_code` из дерева категорий модуля + `attrs` — условия на свойства | | `qty` | нет, деф. 1 | Количество, 0.001 … 10 000 (хранится с точностью до 0.001) | | `position` | нет | `{x, y, rotation}` в координатах полигона комнаты — для визуализации и контроля, на стоимость не влияет | Операторы условий в `selection.attrs`: скаляр = точное совпадение; `{"min": n}` / `{"max": n}` — диапазон чисел; `{"in": [a, b]}` — одно из. Других операторов нет. Где что проверяется: «Шаблон категории» здесь и далее — атрибуты **всего поддерева**: и унаследованные от предков, и заведённые у потомков. Подбор по `category_code` идёт по категории вместе с её подкатегориями, поэтому условие на `seats` (атрибут `furniture.sofa`) законно и при `category_code: "furniture"`. Правило одно на все три точки проверки ниже. - `GET /api/v1/catalog/items?attrs=…` — строгая проверка сразу: неизвестный оператор, пустое условие, нечисловая граница `min`/`max`, атрибут не из шаблона категории или условие не того типа (`true`/`false` по числовому атрибуту и наоборот) дают 422 с пояснением. **Проверяйте условия этим эндпоинтом до отправки планировки** — там же видно, что именно подберётся. - приём планировки (`POST …/layouts`) — **те же правила и та же строгость**: неизвестный оператор, пустое условие, нечисловая граница, пустой `in`, несуществующая категория подбора, атрибут не из шаблона категории, а с этапа 5 и **несовпадение типа условия** (`{"min": 3}` по строковому атрибуту, `true`/`false` по нечисловому) отклоняют планировку целиком (422 со списком проблем). Опечатка в условии не должна всплывать сметой с пропавшим диваном. Формулировка отказа у приёма и у эндпоинта каталога дословно одна. - расчёт сметы: подбор по уже проверенным условиям. Условие, из которого всё же нельзя построить фильтр (например планировка принята более ранней версией модуля), не отбрасывается молча — позиция считается неподобранной, а причина попадает в поле `problem` пометки `ITEM_NOT_RESOLVED`. Модуль подбирает позицию по условиям (из подходящих берёт самую дешёвую по политике цен — правило детерминированное). Подбор выполняется при расчёте сметы, а не при приёме планировки: в ответе на приём у наполнения статус `pending`, после первого расчёта `GET /api/v1/layouts/{id}` показывает `resolved` либо `not_found`. Неподобранный объект **не роняет** расчёт: строки в смете у него нет, но он отражён в списке проблем сметы пометкой `ITEM_NOT_RESOLVED` с указанием комнаты, id объекта и параметров подбора — по ней видно, что именно не нашлось. ## 4. Полный пример: две комнаты с общей дверью ```json { "schema_version": "1.0", "layout_id": "gen-demo-001", "generator_version": "0.3.1", "units": "mm", "rooms": [ { "id": "room-1", "name": "Гостиная", "room_type": "living_room", "height": 2700, "polygon": [[0,0],[5200,0],[5200,3800],[0,3800]], "walls": [ { "index": 0, "openings": [ { "id": "op-w1", "kind": "window", "width": 1500, "height": 1450, "sill_height": 900 } ] }, { "index": 1, "openings": [] }, { "index": 2, "openings": [ { "id": "op-d1", "kind": "door", "width": 900, "height": 2100, "linked_room_id": "room-2" } ] }, { "index": 3, "openings": [] } ], "finishes": { "floor": { "system_code": "floor_laminate" }, "walls": { "system_code": "wall_paint" } }, "placed_items": [ { "id": "pl-1", "selection": { "category_code": "furniture.sofa", "attrs": { "width_mm": { "max": 2400 }, "seats": { "min": 3 } } }, "qty": 1, "position": { "x": 3000, "y": 3400, "rotation": 0 } } ] }, { "id": "room-2", "name": "Спальня", "room_type": "bedroom", "height": 2700, "polygon": [[5200,0],[8400,0],[8400,3800],[5200,3800]], "walls": [ { "index": 0, "openings": [] }, { "index": 1, "openings": [] }, { "index": 2, "openings": [] }, { "index": 3, "openings": [ { "id": "op-d1b", "kind": "door", "width": 900, "height": 2100, "linked_room_id": "room-1" } ] } ] } ] } ``` Обратите внимание: у `room-2` нет `finishes` — модуль применит дефолты для `bedroom`; дверь описана в обеих комнатах и через `linked_room_id` посчитается один раз. ## 5. Приём данных и ответ модуля - Передача: `POST /api/v1/projects/{id}/layouts`, тело — JSON выше. Контракт можно валидировать по JSON Schema: модуль отдаёт её на `GET /api/v1/schema/layout` (эндпоинт публичный и генерируется из тех же моделей, что и валидация приёма, — отдельной копии схемы в репозитории нет, чтобы она не разъезжалась с кодом). - JSON Schema описывает только структуру. Межполевые правила (соответствие `walls` полигону, `sill_height` у окна, «ровно один способ» у `placed_items`, уникальность id, согласованность проёмов) она выразить не может — они перечислены прямо в схеме, в поле `x-additional-rules`. Сверка со схемой без этих правил ещё не гарантирует приём. - Ответ на приём (201): id созданной версии планировки + посчитанная геометрия комнат и поверхностей. - Отказ — всегда 422, но форм тела две, различайте по типу `detail`: - **структурный** (нарушен сам формат: неизвестное поле, не тот тип, число вне границ §1.1) — стандартная форма FastAPI, `detail` = список объектов с `loc`/`msg`/`type`; - **смысловой** (формат верен, посчитать нельзя: самопересечение полигона, проёмы шире стены, не тот или снятый `system_code`, несуществующий `catalog_item_id`) — `detail` = объект `{"message": "планировка отклонена", "problems": ["…", "…"]}`. В обеих формах проблемы собираются **все сразу**, а не по первой, чтобы за один прогон можно было починить всё. - Ошибки консистентности уровня «нельзя посчитать» (самопересекающийся полигон, проёмы шире стены, неизвестный или не тот `system_code`) — планировка отклоняется целиком. Всё остальное (нет отделки, не подобрался диван) — принимается, проблемы отражаются в смете. ### 5.1. Авторизация - Все вызовы API, кроме двух публичных, требуют заголовок **`X-API-Key`**. Ключ выдаёт владелец модуля; для генератора планировок это ключ роли `generator` — он открывает чтение каталога, отправку планировок и расчёт смет. - Публичны ровно два эндпоинта: `GET /api/v1/health` и `GET /api/v1/schema/layout` — схему контракта можно смотреть ещё до получения ключа. - Без заголовка — `401`, с ключом чужой роли — `403`. Ключ передаётся **только** заголовком: в query-параметре он не работает (иначе оседал бы в логах прокси). - Ключ — секрет: не кладите его в репозиторий генератора и в тексты запросов, храните в переменных окружения. ## 6. Эволюция контракта - Добавление новых **опциональных** полей = минорная версия, старые генераторы продолжают работать. - Любое переименование/удаление/изменение семантики = мажорная версия; модуль принимает обе N последних мажорных версии. - Пожелания к контракту (отделка по отдельным стенам, многоуровневые потолки, чистовые/черновые слои) — фиксируйте, обсуждаем до v1.1.