02. Доменная модель каталога и схема БД¶
2.1. Два слоя и правило владения данными¶
┌─────────────────────────── схема iiko ────────────────────────────┐
│ Зеркало источника. Пишет ТОЛЬКО импорт. Человек не редактирует. │
│ organization terminal_group price_category external_menu │
│ product_category menu_category item item_size item_price │
│ item_modifier_group item_modifier item_modifier_price │
│ stop_list sync_run │
└───────────────────────────────┬───────────────────────────────────┘
│ ссылки (FK), только чтение
┌───────────────────────────────▼─────────── схема catalog ─────────┐
│ Витрина. Владелец — админка. Импорт сюда не пишет никогда. │
│ category product product_variant product_placement │
│ product_image product_badge product_visibility │
│ variant_axis variant_option badge │
│ modifier_group_profile modifier_profile │
│ item_triage menu_snapshot │
└───────────────────────────────────────────────────────────────────┘
Инвариант, который нужно защищать кодом и ревью: ни одна колонка не принадлежит обоим слоям.
В legacy это нарушалось (import_dishes правились руками, active жил рядом с импортными полями),
из-за чего каждый импорт затирал ручные правки, и появлялись «защитные» флаги вроде
dish_pizza_import_dishes.active.
Переопределения витрины делаются через отдельные nullable-поля (description_override), а
не через запись в поле источника. Правило чтения: coalesce(catalog.override, iiko.value).
2.2. Ключевые доменные понятия¶
| Понятие | Определение |
|---|---|
Организация (iiko.organization) |
Точка продаж в iiko. Ценообразование, стоп-листы и наличие — всегда в разрезе организации. |
Внешнее меню (iiko.external_menu) |
Настраиваемая в iiko выгрузка номенклатуры. Определяет состав блюд и цены. Их несколько (в legacy — 41188 и 50865, доставка и зал). |
Позиция iiko (iiko.item) |
Единица номенклатуры. Блюдом и модификатором может быть одна и та же сущность, различается ролью. |
Товар (catalog.product) |
Витринная карточка. То, что покупатель видит как одну позицию. |
Вариант (catalog.product_variant) |
Выбор внутри карточки (25/30/35/40 см). Ровно один вариант = ровно одна позиция iiko. Цена берётся у позиции iiko. |
Размещение (catalog.product_placement) |
Товар в категории с позицией сортировки. Товар может быть в нескольких категориях. |
Профиль модификатора (catalog.modifier_profile) |
Витринные настройки модификатора: название для сайта, картинка, порядок, скрытие. |
Триаж (catalog.item_triage) |
Состояние обработки новой позиции iiko: новая / привязана / игнорируется. |
Почему вариант ссылается на item, а не на «размер iiko»¶
В выгрузке itemSizes длины 1 у всех 243 блюд, sizeId = null, а размеры пиццы разведены по
четырём категориям внешнего меню («Пицца 25 см» … «Пицца 40 см») с разными itemId.
Поэтому размер — это витринное понятие, которого в источнике нет.
Но механизм itemSizes в API iiko существует и может быть включён на стороне ресторана в любой
момент, поэтому вариант ссылается на пару (item_id, item_size_id), а не только на item_id.
Это ничего не стоит сейчас и спасает от миграции потом.
2.3. Схема iiko — зеркало источника¶
create schema iiko;
-- ─────────────────────────── справочники ───────────────────────────
create table iiko.organization (
id uuid primary key, -- organizationId из iiko
name text not null,
code text,
country text,
restaurant_address text,
latitude double precision,
longitude double precision,
is_active boolean not null default true,
raw jsonb not null, -- полный объект из iiko
synced_at timestamptz not null,
deleted_at timestamptz
);
create table iiko.terminal_group (
id uuid primary key,
organization_id uuid not null references iiko.organization(id),
name text,
address text,
time_zone text,
is_alive boolean,
raw jsonb not null,
synced_at timestamptz not null,
deleted_at timestamptz
);
create index on iiko.terminal_group (organization_id) where deleted_at is null;
create table iiko.price_category (
id uuid primary key,
name text not null,
synced_at timestamptz not null,
deleted_at timestamptz
);
-- Учётные группы iiko (productCategories, 34 шт.) — бухгалтерская классификация.
-- Используется для правил автопривязки и фильтров в админке, не для витрины.
create table iiko.product_category (
id uuid primary key,
name text not null,
synced_at timestamptz not null,
deleted_at timestamptz
);
-- ─────────────────────────── внешние меню ───────────────────────────
-- Настраивается администратором: какое меню тянуть, с какой ценовой категорией,
-- по каким организациям. В legacy это был хардкод в коде (resolvePriceCategory).
create table iiko.external_menu (
id text primary key, -- строковый id ("41188")
name text not null,
price_category_id uuid references iiko.price_category(id),
is_enabled boolean not null default true,
revision bigint not null default 0, -- последняя импортированная ревизия
last_success_at timestamptz,
last_error text,
sort_order int not null default 0,
comment text
);
create table iiko.external_menu_organization (
external_menu_id text not null references iiko.external_menu(id) on delete cascade,
organization_id uuid not null references iiko.organization(id) on delete cascade,
primary key (external_menu_id, organization_id)
);
-- Категории внешнего меню (itemCategories). Сырьё для подсказок, не витрина.
create table iiko.menu_category (
external_menu_id text not null references iiko.external_menu(id) on delete cascade,
id uuid not null,
name text not null,
description text,
button_image_url text,
header_image_url text,
iiko_group_id uuid,
is_hidden boolean not null default false,
position int not null default 0,
synced_at timestamptz not null,
deleted_at timestamptz,
primary key (external_menu_id, id)
);
-- ─────────────────────────── номенклатура ───────────────────────────
create table iiko.item (
id uuid primary key, -- itemId
sku text,
name text not null,
description text,
item_type text not null, -- DISH | MODIFIER | GOODS | SERVICE
order_item_type text, -- Product | Compound
measure_unit text, -- "порц", "шт"
product_category_id uuid references iiko.product_category(id),
is_hidden boolean not null default false,
can_be_divided boolean,
can_set_open_price boolean,
use_balance_for_sell boolean,
is_marked boolean, -- маркировка (Честный знак)
payment_subject text,
payment_subject_code text,
tax_category jsonb,
allergens jsonb not null default '[]',
tags jsonb not null default '[]',
labels jsonb not null default '[]',
barcodes jsonb,
outer_ean_code text,
raw jsonb not null,
first_seen_at timestamptz not null default now(),
synced_at timestamptz not null,
deleted_at timestamptz
);
create index on iiko.item (item_type) where deleted_at is null;
create index on iiko.item using gin (to_tsvector('russian', name));
create index on iiko.item (sku);
-- Принадлежность позиции категориям внешнего меню (одна позиция может быть
-- в нескольких меню и категориях).
create table iiko.menu_category_item (
external_menu_id text not null,
menu_category_id uuid not null,
item_id uuid not null references iiko.item(id) on delete cascade,
position int not null default 0,
synced_at timestamptz not null,
primary key (external_menu_id, menu_category_id, item_id),
foreign key (external_menu_id, menu_category_id)
references iiko.menu_category(external_menu_id, id) on delete cascade
);
create index on iiko.menu_category_item (item_id);
create table iiko.item_size (
id bigserial primary key,
item_id uuid not null references iiko.item(id) on delete cascade,
size_id uuid, -- null у 100% текущих данных
size_key text not null, -- coalesce(size_id::text, '@default')
sku text,
size_code text,
size_name text,
is_default boolean not null default true,
portion_weight_grams numeric(10,3),
measure_unit_type text, -- GRAM, ...
button_image_url text,
nutrition_per_100g jsonb, -- {fats, proteins, carbs, energy, salt, sugar, saturatedFattyAcid}
nutrition_portion jsonb,
is_hidden boolean not null default false,
synced_at timestamptz not null,
deleted_at timestamptz,
unique (item_id, size_key)
);
-- Цена: (размер позиции, внешнее меню, организация). price = null означает
-- «позиция не продаётся в этой организации» — важно, iiko присылает такие строки.
create table iiko.item_price (
item_size_id bigint not null references iiko.item_size(id) on delete cascade,
external_menu_id text not null references iiko.external_menu(id) on delete cascade,
organization_id uuid not null references iiko.organization(id) on delete cascade,
price numeric(12,2),
synced_at timestamptz not null,
primary key (item_size_id, external_menu_id, organization_id)
);
create index on iiko.item_price (organization_id, external_menu_id) include (price);
-- ─────────────────────────── модификаторы ───────────────────────────
-- ВАЖНО (проверено на выгрузке):
-- 1) состав группы отличается между блюдами при одинаковом itemGroupId
-- (aee0fab6… встречается 163 раза с 2 разными наборами позиций);
-- 2) itemGroupId бывает null (11 вхождений, 3 разных набора);
-- 3) цена одного модификатора зависит от блюда (одна добавка: 35 ₽ в одном
-- блюде и 60 ₽ в другом).
-- Поэтому группа и цены хранятся на уровне размера блюда, а не глобально.
-- Схема legacy (menu/db: modifier_prices по modifier_id+price_category+org)
-- эти три факта теряет — цены модификаторов там неверны.
create table iiko.item_modifier_group (
id bigserial primary key,
item_size_id bigint not null references iiko.item_size(id) on delete cascade,
iiko_group_id uuid, -- itemGroupId, может быть null
group_key text not null, -- coalesce(iiko_group_id::text, 'name:'||name)
name text not null,
description text,
sku text,
position int not null default 0,
is_hidden boolean not null default false,
child_has_min_max boolean, -- childModifiersHaveMinMaxRestrictions
can_be_divided boolean,
min_quantity int, max_quantity int, free_quantity int,
by_default int, hide_if_default_quantity boolean,
unique (item_size_id, group_key)
);
create table iiko.item_modifier (
group_id bigint not null references iiko.item_modifier_group(id) on delete cascade,
modifier_item_id uuid not null references iiko.item(id),
position int not null default 0,
min_quantity int, max_quantity int, free_quantity int,
by_default int, hide_if_default_quantity boolean,
independent_quantity boolean,
is_hidden boolean not null default false,
portion_weight_grams numeric(10,3),
nutrition_per_100g jsonb,
primary key (group_id, modifier_item_id)
);
create table iiko.item_modifier_price (
group_id bigint not null,
modifier_item_id uuid not null,
external_menu_id text not null references iiko.external_menu(id) on delete cascade,
organization_id uuid not null references iiko.organization(id) on delete cascade,
price numeric(12,2),
primary key (group_id, modifier_item_id, external_menu_id, organization_id),
foreign key (group_id, modifier_item_id)
references iiko.item_modifier(group_id, modifier_item_id) on delete cascade
);
Оценка объёма. 21 231 связка «группа-модификатор» × число организаций. При 30 организациях — ~640 тыс. строк цен модификаторов на одно внешнее меню. Для Postgres это немного, но таблица перезаписывается импортом целиком, поэтому запись — через
COPYво временную таблицу иinsert … on conflict do updateпакетами (см. 03-iiko-sync §3.6), а не построчными update.
-- ─────────────────────────── стоп-листы ───────────────────────────
create table iiko.stop_list (
organization_id uuid not null references iiko.organization(id) on delete cascade,
terminal_group_id uuid not null,
item_id uuid not null references iiko.item(id) on delete cascade,
size_id uuid,
size_key text not null default '@default',
balance numeric(12,3) not null default 0,
synced_at timestamptz not null,
primary key (organization_id, terminal_group_id, item_id, size_key)
);
create index on iiko.stop_list (item_id);
-- ─────────────────────────── журнал синхронизаций ───────────────────
create table iiko.sync_run (
id bigserial primary key,
job text not null, -- ORGANIZATIONS | TERMINALS | MENU | STOP_LIST | NOMENCLATURE
scope text, -- напр. external_menu_id
mode text not null, -- INCREMENTAL | FULL
status text not null, -- RUNNING | SUCCESS | FAILED | SKIPPED
trigger text not null, -- SCHEDULE | MANUAL | WEBHOOK
triggered_by text, -- логин админа при MANUAL
revision_from bigint, revision_to bigint,
started_at timestamptz not null default now(),
finished_at timestamptz,
duration_ms bigint,
stats jsonb not null default '{}', -- {created, updated, deleted, skipped, ...}
error text
);
create index on iiko.sync_run (job, started_at desc);
2.4. Схема catalog — витрина¶
create schema catalog;
-- ─────────────────────────── категории ───────────────────────────
create table catalog.category (
id bigserial primary key,
parent_id bigint references catalog.category(id),
name text not null,
slug text not null,
short_description text,
description text,
image_id bigint references media.file(id),
icon_id bigint references media.file(id),
position int not null default 0,
is_active boolean not null default false,
show_in_menu boolean not null default true, -- показывать в главной навигации
seo_title text, seo_description text, seo_h1 text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
version int not null default 0,
unique (slug)
);
create index on catalog.category (parent_id, position);
Глубина дерева ограничивается двумя уровнями на уровне валидации (в legacy было плоско; больше двух уровней витрина не отображает, а модель позволяет — ограничение сознательно в коде, а не в схеме).
-- ─────────────────────── оси вариативности ───────────────────────
-- «Размер пиццы» (25/30/35/40 см), «Объём напитка» (0.3/0.5 л) и т. п.
create table catalog.variant_axis (
id bigserial primary key,
code text not null unique, -- pizza_size, drink_volume
name text not null, -- «Размер»
ui_type text not null default 'SEGMENTED', -- SEGMENTED | SELECT | TILES
position int not null default 0
);
create table catalog.variant_option (
id bigserial primary key,
axis_id bigint not null references catalog.variant_axis(id) on delete cascade,
code text not null, -- 25, 30, 35, 40
name text not null, -- «25 см»
short_name text, -- «25»
position int not null default 0,
is_active boolean not null default true,
unique (axis_id, code)
);
-- ─────────────────────────── товар ───────────────────────────
create table catalog.product (
id bigserial primary key,
slug text not null unique,
name text not null, -- витринное название («Панам»), без «25 см»
subtitle text,
description text, -- витринное описание; null → берётся из iiko
composition text, -- состав отдельным полем
variant_axis_id bigint references catalog.variant_axis(id), -- null = товар без вариантов
status text not null default 'DRAFT', -- DRAFT | PUBLISHED | ARCHIVED
is_secret boolean not null default false, -- доступен только по прямой ссылке
main_image_id bigint references media.file(id),
hover_image_id bigint references media.file(id),
default_variant_id bigint, -- FK добавляется после product_variant
seo_title text, seo_description text, seo_h1 text,
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
created_by bigint, updated_by bigint,
version int not null default 0
);
create index on catalog.product (status);
-- ─────────────────────────── вариант ───────────────────────────
create table catalog.product_variant (
id bigserial primary key,
product_id bigint not null references catalog.product(id) on delete cascade,
iiko_item_id uuid not null references iiko.item(id),
iiko_item_size_id bigint not null references iiko.item_size(id),
variant_option_id bigint references catalog.variant_option(id),
name_override text,
weight_override numeric(10,3),
image_id bigint references media.file(id),
position int not null default 0,
is_active boolean not null default true,
created_at timestamptz not null default now(),
-- одна позиция iiko принадлежит максимум одному товару
unique (iiko_item_size_id)
);
create index on catalog.product_variant (product_id, position);
create index on catalog.product_variant (iiko_item_id);
alter table catalog.product
add constraint product_default_variant_fk
foreign key (default_variant_id) references catalog.product_variant(id) on delete set null;
Ограничение unique (iiko_item_size_id) — центральный инвариант модели. Оно гарантирует, что
блюдо iiko не окажется в двух карточках, и делает вопрос «какие блюда ещё не размещены на витрине»
дешёвым запросом (left join … where pv.id is null).
Снимок привязки и инциденты¶
Реальный сценарий эксплуатации: сотрудник случайно удаляет блюдо в iiko или пересоздаёт его —
и то же по смыслу блюдо приходит с новым itemId. Ссылка варианта на iiko.item при этом
становится ссылкой на удалённую строку, а товар молча пропадает с витрины.
Поэтому вариант хранит не только ссылку, но и слепок состояния позиции iiko: он обновляется при каждом успешном импорте, пока позиция жива, и замораживается в момент пропажи.
create table catalog.variant_binding (
variant_id bigint primary key references catalog.product_variant(id) on delete cascade,
-- слепок на момент привязки: чем была позиция, когда её выбрал человек
bound_at timestamptz not null default now(),
bound_by bigint references platform.admin_user(id),
bound_item_id uuid not null,
bound_sku text,
bound_name text not null,
bound_size_name text,
bound_product_category_id uuid,
bound_menu_category_id uuid,
bound_price numeric(12,2), -- цена в опорной организации на момент привязки
bound_weight_grams numeric(10,3),
bound_snapshot jsonb not null, -- полный слепок позиции
-- последнее известное живое состояние: обновляется каждым успешным импортом
last_seen_at timestamptz not null default now(),
last_seen_name text,
last_seen_sku text,
last_seen_price numeric(12,2),
last_seen_snapshot jsonb,
-- состояние привязки
state text not null default 'OK', -- OK | MISSING | RESTORED | REBOUND
missing_since timestamptz,
missing_import_run_id bigint references iiko.sync_run(id),
-- история перепривязок: куда указывал вариант раньше
previous_item_ids uuid[] not null default '{}'
);
create index on catalog.variant_binding (state) where state <> 'OK';
-- Инцидент — событие, требующее решения человека. Не удаляется, закрывается.
create table catalog.binding_incident (
id bigserial primary key,
variant_id bigint references catalog.product_variant(id) on delete cascade,
product_id bigint references catalog.product(id) on delete cascade,
iiko_item_id uuid,
type text not null, -- ITEM_MISSING | ITEM_RESTORED | NAME_CHANGED
-- | PRICE_GONE | POSSIBLE_REPLACEMENT | SIZE_MISSING
severity text not null, -- INFO | WARNING | CRITICAL
status text not null default 'OPEN', -- OPEN | RESOLVED | DISMISSED
detected_at timestamptz not null default now(),
detected_by_run_id bigint references iiko.sync_run(id),
details jsonb not null default '{}', -- прежнее/новое значение, кандидаты на замену
resolution text, -- REBOUND | VARIANT_DISABLED | PRODUCT_UNPUBLISHED
-- | ITEM_RETURNED | IGNORED
resolved_at timestamptz,
resolved_by bigint references platform.admin_user(id),
note text
);
create index on catalog.binding_incident (status, severity, detected_at desc);
create index on catalog.binding_incident (product_id) where status = 'OPEN';
Как это работает при пропаже позиции.
- Импорт помечает
iiko.item.deleted_at. Ничего вcatalogне удаляется. - Слушатель в модуле
catalogпереводитvariant_binding.state = MISSING, ставитmissing_sinceи создаёт инцидентITEM_MISSING. - Порог «мигания»: если позиция вернулась в течение
binding.missing_grace(по умолчанию 2 импорта или 30 минут), инцидент закрывается автоматически с резолюциейITEM_RETURNED, состояние возвращается вOK. Это отсекает шум от частичных выгрузок iiko. - Поиск замены: среди позиций, появившихся после пропажи, ищутся кандидаты по совпадению
sku(сильный сигнал), затем по нормализованному названию + учётной группе. Найденные попадают вdetails.candidatesи порождают инцидентPOSSIBLE_REPLACEMENT. - Перепривязка одним действием:
POST /variants/{id}/rebindменяетiiko_item_size_id, дописывает старый id вprevious_item_ids, ставитstate = REBOUND, закрывает инциденты. Товар, картинки, тексты, SEO, позиция в категории — не затрагиваются. - Если живых вариантов не осталось — товар снимается с публикации, с причиной и уведомлением. При появлении замены и перепривязке публикация восстанавливается в один клик.
Дополнительно last_seen_* даёт бесплатный детектор расхождений: если в iiko переименовали блюдо,
создаётся NAME_CHANGED (severity INFO), и в админке видно расхождение витринного названия с
источником — без автоматической подмены названия на витрине.
-- ─────────────────── размещение и сортировка ───────────────────
create table catalog.product_placement (
product_id bigint not null references catalog.product(id) on delete cascade,
category_id bigint not null references catalog.category(id) on delete cascade,
position int not null default 0,
is_primary boolean not null default false, -- категория для «хлебных крошек» и URL
primary key (product_id, category_id)
);
create index on catalog.product_placement (category_id, position);
create unique index on catalog.product_placement (product_id) where is_primary;
-- ─────────────────────────── медиа товара ───────────────────────────
create table catalog.product_image (
id bigserial primary key,
product_id bigint not null references catalog.product(id) on delete cascade,
variant_id bigint references catalog.product_variant(id) on delete cascade,
media_id bigint not null references media.file(id),
role text not null default 'GALLERY', -- MAIN | HOVER | GALLERY
position int not null default 0
);
-- ─────────────────────────── бейджи ───────────────────────────
create table catalog.badge (
id bigserial primary key,
code text not null unique, -- new, hit, spicy, veg
name text not null,
color text,
icon_id bigint references media.file(id),
position int not null default 0,
is_active boolean not null default true
);
create table catalog.product_badge (
product_id bigint not null references catalog.product(id) on delete cascade,
badge_id bigint not null references catalog.badge(id) on delete cascade,
primary key (product_id, badge_id)
);
-- ─────────────────── видимость по организациям ───────────────────
-- Заменяет legacy blocked_dish_city. Отсутствие строки = товар виден.
create table catalog.product_visibility (
product_id bigint not null references catalog.product(id) on delete cascade,
organization_id uuid not null references iiko.organization(id) on delete cascade,
is_hidden boolean not null default true,
reason text,
primary key (product_id, organization_id)
);
-- ─────────────── витринные настройки модификаторов ───────────────
-- Ключ — стабильный ключ группы iiko; профиль общий для всех блюд,
-- где встречается эта группа.
create table catalog.modifier_group_profile (
id bigserial primary key,
group_key text not null unique, -- совпадает с iiko.item_modifier_group.group_key
display_name text,
ui_type text not null default 'AUTO', -- AUTO | RADIO | CHECKBOX | COUNTER
position int not null default 0,
is_hidden boolean not null default false,
is_collapsed boolean not null default false,
hint text
);
create table catalog.modifier_profile (
iiko_item_id uuid primary key references iiko.item(id) on delete cascade,
display_name text,
image_id bigint references media.file(id),
position int not null default 0,
is_hidden boolean not null default false,
color text
);
-- ─────────────────── очередь обработки новых позиций ───────────────────
create table catalog.item_triage (
iiko_item_id uuid primary key references iiko.item(id) on delete cascade,
status text not null default 'NEW', -- NEW | MAPPED | IGNORED
note text,
decided_by bigint,
decided_at timestamptz,
created_at timestamptz not null default now()
);
create index on catalog.item_triage (status);
-- ─────────────────────── снапшот витрины ───────────────────────
-- Материализованное меню на организацию. Строится по событию, отдаётся
-- публичным API. Дублируется в Redis; таблица — источник для прогрева.
create table catalog.menu_snapshot (
organization_id uuid not null references iiko.organization(id) on delete cascade,
external_menu_id text not null references iiko.external_menu(id) on delete cascade,
revision bigint not null, -- монотонный счётчик витрины
etag text not null,
payload jsonb not null,
payload_bytes int not null,
built_at timestamptz not null default now(),
primary key (organization_id, external_menu_id)
);
2.5. Схема media¶
create schema media;
create table media.file (
id bigserial primary key,
storage_key text not null unique, -- ключ в S3
original_name text,
mime text not null,
size_bytes bigint not null,
width int, height int,
sha256 text not null unique, -- дедупликация повторных загрузок
alt text,
focal_point jsonb, -- {x, y} для умного кропа
folder text, -- products / categories / badges
created_by bigint,
created_at timestamptz not null default now()
);
create table media.rendition (
file_id bigint not null references media.file(id) on delete cascade,
preset text not null, -- thumb | card | card@2x | detail | detail@2x
format text not null, -- webp | jpeg | avif
storage_key text not null,
width int not null, height int not null,
size_bytes bigint not null,
primary key (file_id, preset, format)
);
Пресеты фиксируются в конфигурации, при добавлении нового пресета — фоновая догенерация для всех файлов. Отдача — через CDN/nginx по прямому ключу S3, backend в раздаче не участвует.
2.6. Схема platform — пользователи админки, права, аудит¶
create schema platform;
create table platform.admin_user (
id bigserial primary key,
email text not null unique,
full_name text not null,
password_hash text, -- null, если только SSO
is_active boolean not null default true,
last_login_at timestamptz,
created_at timestamptz not null default now()
);
create table platform.role (
id bigserial primary key,
code text not null unique, -- ADMIN | CONTENT_MANAGER | VIEWER
name text not null
);
create table platform.role_permission (
role_id bigint not null references platform.role(id) on delete cascade,
permission text not null, -- catalog.product.write, iiko.sync.run, ...
primary key (role_id, permission)
);
create table platform.admin_user_role (
user_id bigint not null references platform.admin_user(id) on delete cascade,
role_id bigint not null references platform.role(id) on delete cascade,
primary key (user_id, role_id)
);
create table platform.audit_log (
id bigserial primary key,
user_id bigint references platform.admin_user(id),
user_email text,
action text not null, -- CREATE | UPDATE | DELETE | PUBLISH | SYNC_RUN
entity_type text not null, -- catalog.product, catalog.category
entity_id text not null,
diff jsonb, -- {field: {before, after}}
ip inet,
request_id text,
created_at timestamptz not null default now()
);
create index on platform.audit_log (entity_type, entity_id, created_at desc);
create index on platform.audit_log (user_id, created_at desc);
Аудит пишется декоративно (аспект на команды изменения), не вручную в каждом сервисе.
Дополнительно в схеме platform живут служебные таблицы: event_publication (Event Publication
Registry Spring Modulith — транзакционный outbox внутренних событий) и shedlock (защита от
параллельного запуска задач синхронизации). Обе создаются миграциями явно, а не автогенерацией,
чтобы схема БД была полностью описана в Flyway.
2.7. Правила разрешения значений при сборке витрины¶
| Поле витрины | Правило |
|---|---|
| Название товара | product.name (обязательное, вводит контент-менеджер) |
| Название варианта | coalesce(variant.name_override, variant_option.name, iiko.item.name) |
| Описание | coalesce(product.description, iiko.item.description) |
| Состав | product.composition, иначе распарсенное описание iiko |
| Вес | coalesce(variant.weight_override, iiko.item_size.portion_weight_grams) |
| КБЖУ | iiko.item_size.nutrition_per_100g (только источник) |
| Цена | iiko.item_price по (организация, внешнее меню) — только источник |
| Наличие | iiko.stop_list по организации/терминалу — только источник |
| Картинка | catalog.product_image / variant.image_id; buttonImageUrl из iiko не используется |
| Аллергены | iiko.item.allergens |
| Видимость | product.status = PUBLISHED ∧ catalog.product_visibility ∧ есть цена в этой организации |
Описания в iiko зашумлены («… соус томатный, пармезан КБЖУ на 100гр: б:9г,ж:9г,у:21г,ккал: 196»). При импорте применяется парсер, который вытаскивает КБЖУ-хвост в структурные поля, а остаток кладёт как предложенный состав — но записывается это не в поле источника, а в подсказку для контент-менеджера при создании карточки (см. 04-admin-catalog §4.5).
2.8. Условия публикации товара¶
Товар не может быть переведён в PUBLISHED, если:
- нет ни одного активного варианта;
- у варианта нет привязки к существующей (не удалённой) позиции iiko;
- нет главного изображения;
- нет ни одного размещения в активной категории;
slugне уникален.
Проверка возвращается админке структурировано (список нарушенных правил), а не одним текстом — экран карточки показывает чек-лист «что мешает опубликовать».
2.9. Реакция на изменения в iiko¶
| Событие импорта | Поведение витрины |
|---|---|
| Появилась новая позиция | Строка в catalog.item_triage со статусом NEW, бейдж-счётчик в админке |
| Позиция исчезла из меню | iiko.item.deleted_at; variant_binding.state = MISSING, инцидент ITEM_MISSING, слепок замораживается; товар снимается с публикации только если не осталось живых вариантов |
| Позиция вернулась в течение окна ожидания | Автозакрытие инцидента (ITEM_RETURNED), состояние OK, публикация восстанавливается |
| Появилась позиция с тем же SKU/названием, что у пропавшей | Инцидент POSSIBLE_REPLACEMENT с кандидатами, перепривязка в одно действие |
| Изменилась цена | Ничего не требуется — пересборка снапшота |
| Пропала цена в организации | Инцидент PRICE_GONE; товар перестаёт отдаваться в этой организации |
| Позиция попала в стоп-лист | Пересборка/патч снапшота, товар помечается «нет в наличии» (не скрывается — решается настройкой) |
| Изменилось название в iiko | Витринное название не меняется. Инцидент NAME_CHANGED (INFO), в админке — индикатор расхождения с возможностью принять новое |
Автоудаление карточек витрины запрещено. Товар — созданный человеком контент с SEO-историей и загруженными изображениями; его снимают с публикации, но не удаляют. Ошибка в iiko (случайное удаление блюда) не должна стоить контент-менеджеру повторной сборки карточки.