renovation-estimator · этап 1 · гейт согласования
Структура базы данных модуля расчёта сметы ремонта
НА СОГЛАСОВАНИИ
20 таблиц в пяти блоках. Схема уже развёрнута в PostgreSQL с демо-данными и проверена живыми запросами — но это черновик: правки на этом этапе дешёвые, дальше разработка не двигается до утверждения.
- Каталог
- Расчётные правила
- Модель помещения
- Сметы
- Импорт
1Общая карта связей
Как блоки сцеплены: каталог наполняется через импорт; планировка от генератора раскладывается в модель помещения; движок соединяет поверхности с рецептами и каталогом и пишет смету.
erDiagram
category ||--o{ category : "родитель"
category ||--o{ item : "содержит"
category ||--o{ attribute_def : "схема атрибутов"
item ||--o{ price : "цены (история)"
supplier ||--o{ price : "предлагает"
item ||--o{ item_source : "привязка к источнику"
import_source ||--o{ item_source : "ключ идемпотентности"
import_source ||--o{ import_batch : "сеансы загрузки"
import_batch ||--o{ staging_item : "строки"
finish_system ||--o{ finish_system_component : "состав рецепта"
finish_system_component }o--o| item : "конкретная позиция"
finish_system_component }o--o| category : "или подбор из категории"
room_type_default }o--|| finish_system : "дефолт по типу комнаты"
project ||--o{ layout : "версии планировки"
layout ||--o{ room : "комнаты"
room ||--o{ surface : "пол/стены/потолок"
surface ||--o{ opening : "проёмы"
surface }o--o| finish_system : "назначенная отделка"
room ||--o{ placed_item : "наполнение"
placed_item }o--o| item : "резолв в каталог"
project ||--o{ estimate : "версии смет"
estimate }o--|| layout : "по планировке"
estimate ||--o{ estimate_line : "строки"
estimate ||--o{ estimate_issue : "проблемы"
estimate_line }o--o| item : "позиция"
estimate_line }o--o| price : "снапшот цены"
2Ключевые решения схемы
- Атрибуты: JSONB + шаблоны по категориям. Свойства позиции лежат одним JSONB-полем
item.attrs (быстрый подбор GIN-индексом), а attribute_def задаёт для каждой категории допустимые ключи и типы — валидация при импорте. Ни тяжёлого EAV, ни бесконтрольной свалки ключей.
- Одна таблица
item для всего. Материалы, наполнение и работы — единый каталог, различаются типом категории. Смета собирается из однородных строк, рецепты ссылаются на что угодно.
- Цены append-only. Новая цена не затирает старую: история полная, актуальная помечена
is_current. У позиции может быть несколько цен (поставщики, типы цен).
- Рецепты вместо жёсткого кода. «Пол ламинат» = строки рецепта: каждая знает, от чего считается (площадь / периметр / число проёмов), норму расхода, запас на подрезку и правило округления до упаковки. Новые виды отделки — данными, без программиста.
- Наполнение: ссылка или подбор. Генератор может указать конкретную позицию каталога, а может — параметры («диван до 2.4 м, от 3 мест»): модуль сам подберёт и запишет, что и почему выбрано.
- Импорт через карантин. Любой источник (Excel, CSV, API, ручной) грузится сначала в staging, проверяется, сопоставляется по артикулу источника и только потом попадает в каталог. Повторная загрузка того же файла ничего не ломает.
- Сметы версионные и неизменные. Пересчёт — новая версия; в каждой строке снапшот цены и полный след расчёта: видно, из какой нормы и какого запаса получилась каждая цифра.
3Блок «Каталог»
Что бывает на свете: материалы, наполнение, работы, их свойства и цены.
category
Дерево категорий: материалы / наполнение / работы
idPK
parent_idFK→category
code flooring.laminateUNIQUE
name
kind material / furnishing / work
path материализованный путь
sort_order
unit
Единицы измерения
idPK
code m2, m, pcs, l, kg, packUNIQUE
name
dimension area / length / count / volume / mass
attribute_def
Схема атрибутов категории (шаблон для attrs)
idPK
category_idFK→category
code width_mm, color, power_w
name
data_type number / string / bool / enum
unit_code nullable
enum_values JSONB
is_required
Атрибуты наследуются вниз по дереву категорий
item
Позиция каталога — материал, объект наполнения или работа
idPK
category_idFK→category
name, description
base_unit_idFK→unit
attrs JSONB + GIN-индекс
package_qty 2.13 м²/пачка — для округления
package_unit_idFK→unit
image_url, is_active
created_at, updated_at
supplier
Поставщик / источник цен
idPK
codeUNIQUE
name, url, notes
priority порядок предпочтения при выборе цены
is_service служебный «без источника», с реальными не сливается
price
Цена позиции; история не затирается
idPK
item_idFK→item
supplier_idFK→supplier
price, currency RUB
unit_id цена может быть за упаковкуFK→unit
price_type retail / trade / estimate
valid_from, collected_at
is_current частичный UNIQUE-индекс
4Блок «Расчётные правила»
Как из квадратных метров получаются литры, пачки и рубли: рецепты отделки с нормами расхода.
finish_system
Рецепт отделки поверхности: «пол ламинат», «стены покраска»
idPK
code floor_laminateUNIQUE
name, description
surface_kind floor / wall / ceiling
is_active
finish_system_component
Строка рецепта: компонент + норма расхода
idPK
system_idFK→finish_system
role «подложка», «укладка», sort_order
component_ref_type item / category
item_id конкретная позиция…FK→item
category_id …или подбор из категорииFK→category
measure_basis area / perimeter / opening_count / room_count / piece
consumption_rate 0.25 л/м² + consumption_unit_id
waste_factor запас/подрезка, 1.05
rounding none / up_unit / up_package
params {"layers": 2}, condition JSONB
Плинтус считается от периметра минус двери, краска — от площади, порожек — от числа проёмов
room_type_default
Отделка по умолчанию, если генератор не указал
idPK
room_type bathroom, kitchen, bedroom…
surface_kind
finish_system_idFK→finish_system
style_profile задел: эконом / комфорт / бизнес
5Блок «Модель помещения»
Планировка от генератора, разложенная в расчётные единицы: комнаты → поверхности → проёмы, плюс расставленное наполнение.
project
Проект (квартира / объект)
idPK
name, customer, status
created_at
layout
Принятая планировка — иммутабельная версия входа
idPK
project_idFK→project
external_layout_id id в генераторе
generator_version, schema_version
raw_json вход целиком — для переразбора
imported_at
Новая версия от генератора = новая строка, старая не меняется
room
Комната
idPK
layout_idFK→layout
external_id, name, room_type
height_mm
floor_area_m2, perimeter_m
skirting_length_m периметр минус двери и проходы
geometry полигон, мм
surface
Поверхность — то, к чему применяется рецепт
idPK
room_idFK→room
kind floor / wall / ceiling
wall_index, length_mm для стен
gross_area_m2 − openings_area_m2 = net_area_m2
finish_system_idFK→finish_system
finish_source layout / room_default / manual
opening
Проём: вычитается из стены, дверь — и из периметра плинтуса
idPK
surface_idFK→surface
kind door / window / passage
width_mm, height_mm, area_m2
sill_height_mm, offset_mm откосы, подоконники, раскладка
linked_room_external_id межкомнатная дверь не считается дважды
placed_item
Расставленное наполнение
idPK
room_idFK→room
external_id
catalog_item_id конкретная позиция…FK→item
selection_params …или параметры подбора, JSONB
qty, position {x, y, rotation}
resolved_item_id что подобралиFK→item
resolution_status pending / resolved / ambiguous / not_found
6Блок «Сметы»
Результат: версионные сметы, каждая цифра со следом происхождения.
estimate
Расчёт; пересчёт = новая версия, старые неизменны
idPK
project_idFK→project
layout_idFK→layout
version UNIQUE в рамках проекта
status draft / final
pricing_policy JSONB: приоритет поставщиков, тип цены
total_materials / _furnishing / _works
grand_total, created_at
estimate_line
Строка сметы
idPK
estimate_idFK→estimate
section floor / walls / ceiling / furnishing / works
room_id NULL = итог по объектуFK→room
item_idFK→item
measured_qty замер: 18.4 м²
qty к закупке: 10 пачек + unit_id
unit_price, price_id какая цена взятаFK→price
total
calc_trace JSONB: рецепт, базис, норма, запас, округление
estimate_issue
Проблемы расчёта — не роняют смету
idPK
estimate_idFK→estimate
severity warning / error
code NO_PRICE, ITEM_NOT_RESOLVED, NO_FINISH_SYSTEM
context JSONB
7Блок «Импорт каталога»
Гибкий вход под будущие источники: сырьё → карантин → сопоставление → каталог. Повторная загрузка того же файла = 0 изменений.
import_source
Зарегистрированный источник + правила преобразования
idPK
code leroy_csv, manualUNIQUE
name
source_type csv / xlsx / json / api / manual
mapping_profile JSONB: колонки → каноника, трансформации
default_supplier_idFK→supplier
item_source
Привязка позиции к артикулу источника — ключ идемпотентности
idPK
item_idFK→item
source_idFK→import_source
external_key артикул / SKU / URL
raw_snapshot JSONB
last_seen_at
UNIQUE (source_id, external_key) — повторный импорт обновляет, а не дублирует
import_batch
Сеанс загрузки
idPK
source_idFK→import_source
file_name
status loaded / mapped / applied / failed
stats {"rows":120,"created":15,"updated":100}
started_at, finished_at
staging_item
Сырая строка в карантине до применения
idPK
batch_idFK→import_batch
row_num
raw как пришло, canonical после маппинга
external_key
match_status new / matched / conflict / error
error_text
applied_item_idFK→item
8Три сквозных примера на живых данных
Демо-данные уже в базе; расчёты ниже показывают, как схема отработает типовые случаи.
Краска: норма расхода
item «Краска в/д белая матовая», ведро 9 л → рецепт wall_paint, компонент «краска»
| стены комнаты (за вычетом проёмов) | net_area = 36.8 м² |
| норма (2 слоя, params {"layers": 2}) | 36.8 × 0.25 л/м² = 9.20 л |
| запас (waste 1.05) | 9.20 × 1.05 = 9.66 л |
| округление up_package (ведро 9 л) | ⌈9.66 / 9⌉ = 2 ведра (18 л) |
| цена 5 490 ₽/ведро (Поставщик А) | 2 × 5 490 = 10 980 ₽ |
Ламинат: упаковка и две цены
item «Ламинат Дуб Натур 33», package_qty 2.13 м²/пачка, цены: А 1 290 ₽/м² (priority 10), Б 1 245 ₽/м² (priority 20)
| пол комнаты | net_area = 18.4 м² |
| запас на подрезку (waste 1.05) | 18.4 × 1.05 = 19.32 м² |
| округление up_package | ⌈19.32 / 2.13⌉ = 10 пачек = 21.3 м² |
| политика «мин. цена» → Поставщик Б | 21.3 × 1 245 = 26 518.50 ₽ |
| в строке сметы | measured_qty=18.4 м², qty=10 пачек, price_id→снапшот цены Б |
Диван: подбор по параметрам
генератор прислал не конкретную модель, а требования → placed_item.selection_params → JSONB-подбор → resolved_item
| запрос генератора | {"category_code":"furniture.sofa", "attrs":{"width_mm":{"max":2400}, "seats":{"min":3}}} |
| подбор по каталогу (реально выполнен в БД) | → «Диван 3-местный „Осло“, рогожка» {width_mm: 2200, seats: 3} |
| результат | resolution_status = resolved, resolved_item_id → «Осло» |
| цена (Поставщик Б) | 68 900 ₽ → раздел «Наполнение» |
Что согласовываем
- Механизм гибких атрибутов: JSONB-значения + шаблоны по категориям.
- Единый каталог
item для материалов, наполнения и работ.
- Цены: несколько поставщиков, история, выбор по политике при расчёте.
- Рецепты отделки как данные: норма × базис × запас × округление до упаковки.
- Наполнение от генератора: конкретной позицией или параметрами подбора.
- Импорт через staging с маппинг-профилями — под любые будущие источники.
- Сметы: версии, снапшот цен, след расчёта в каждой строке.
Если структура устраивает — по слову «согласовано» продолжаю этапы 2–5 (импорт каталога, контракт с генератором, движок расчёта, экспорт). Любые правки схемы сейчас — самые дешёвые.