Целевая схема БД каталога¶
Статус: рабочая схема новой модели. Она заменяет прежнюю детализацию каталога и импорта там, где решения расходятся.
1. Слои данных¶
erDiagram
RESTAURANT ||--o{ IMPORT_RUN : запускает
RESTAURANT ||--o{ IMPORTED_DISH : имеет
IMPORT_RUN ||--o{ IMPORTED_DISH : создаёт
IMPORTED_DISH ||--o{ IMPORTED_MODIFIER : содержит
PRODUCT ||--o{ PRODUCT_VARIANT : варианты
CATEGORY }o--|| GRID : использует
GRID ||--o{ GRID_ITEM : содержит
PRODUCT ||--o{ GRID_PRODUCT_ITEM : размещён
GRID_ITEM ||--o| GRID_PRODUCT_ITEM : представляет
PRODUCT ||--o{ PRODUCT_OPTION : предлагает
OPTION ||--o{ PRODUCT_OPTION : подключена
OPTION ||--o{ OPTION_IIKO_BINDING : связана
PRODUCT ||--o{ PRODUCT_TAG : имеет
TAG ||--o{ PRODUCT_TAG : назначен
Три независимых слоя:
restaurant— выбранные организация, external menu и терминал iiko;iiko_import— обновляемая проекция последнего корректного состояния данных iiko;catalog— стабильные виртуальные товары, варианты, категории, картинки, теги и опции сайта.
Между catalog и iiko_import нет внешних ключей. Их связывают UUID iiko при чтении. Поэтому
проблема импорта или административная очистка проекции не удаляет настроенный каталог.
Ценовую категорию мы не храним и не передаём в iiko. Группа модификаторов не является доменной
сущностью: её UUID сохраняется в импортированном вхождении только для productGroupId заказа.
2. Ресторан¶
create schema if not exists restaurant;
create table restaurant.restaurant (
id bigint generated always as identity primary key,
iiko_connection_id bigint not null,
iiko_organization_id uuid not null,
name text not null,
slug text not null unique,
address text,
timezone text,
iiko_external_menu_id text,
iiko_terminal_group_id uuid,
status text not null default 'DRAFT'
check (status in ('DRAFT', 'ACTIVE', 'DISABLED', 'CLOSED')),
closed_at timestamptz,
close_reason text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (iiko_connection_id, iiko_organization_id)
);
Ресторан создаётся в статусе DRAFT при импорте новой организации iiko. Привязка
(iiko_connection_id, iiko_organization_id) неизменяема, CLOSED — терминальный статус. Через
подключение ресторан принадлежит юрлицу франчайзи, поэтому legal_entity_id и franchisee_id в
таблице не дублируются. iiko_external_menu_id и iiko_terminal_group_id заполняются при настройке
и обязательны для перехода в ACTIVE. Недоступность из-за отключения франчайзи хранится отдельной
операционной блокировкой и не переписывает status. Модель и переходы — в
модели франчайзинга.
У ресторана одновременно активно одно external menu. terminal_group_id используется только для
маршрутизации заказа и не входит в ключ цены. pending_external_menu_id не нужен: при редкой смене
меню допустимо временно показать пустой каталог.
3. Импортированная проекция iiko¶
Запуск импорта¶
create schema if not exists iiko_import;
create type iiko_import.run_status as enum (
'RUNNING', 'SUCCEEDED', 'SUCCEEDED_WITH_WARNINGS', 'FAILED'
);
create type iiko_import.entity_state as enum ('OK', 'INVALID', 'MISSING');
create table iiko_import.import_run (
id bigint generated always as identity primary key,
restaurant_id bigint not null references restaurant.restaurant(id),
external_menu_id text not null,
organization_id uuid not null,
source_revision bigint,
status iiko_import.run_status not null,
statistics jsonb not null default '{}',
error text,
started_at timestamptz not null default now(),
finished_at timestamptz
);
create index import_run_restaurant_idx
on iiko_import.import_run (restaurant_id, started_at desc);
create table iiko_import.import_issue (
id bigint generated always as identity primary key,
import_run_id bigint not null
references iiko_import.import_run(id) on delete cascade,
entity_type text not null, -- DISH, MODIFIER
dish_id uuid,
size_id uuid,
modifier_id uuid,
group_id uuid,
code text not null, -- NULL_PRICE, INVALID_UUID, MISSING, ...
message text not null,
source_fragment jsonb,
created_at timestamptz not null default now()
);
create index import_issue_run_idx
on iiko_import.import_issue (import_run_id, entity_type, code);
statistics содержит агрегированные счётчики, а import_issue — конкретные UUID и причины проблем.
Ошибки отдельных блюд не переводят весь запуск в FAILED: успешные строки применяются, а запуск
получает SUCCEEDED_WITH_WARNINGS. FAILED означает, что нельзя было безопасно обработать ответ
целиком: например, сеть недоступна, JSON оборван или не удалось открыть транзакцию.
Блюдо ресторана¶
create table iiko_import.imported_dish (
id bigint generated always as identity primary key,
restaurant_id bigint not null
references restaurant.restaurant(id) on delete cascade,
created_by_run_id bigint not null references iiko_import.import_run(id),
last_seen_run_id bigint not null references iiko_import.import_run(id),
last_updated_by_run_id bigint not null references iiko_import.import_run(id),
dish_id uuid not null,
size_id uuid,
source_name text not null,
source_sku text,
source_description text,
price numeric(12,2) not null check (price >= 0),
source_data jsonb not null default '{}',
state iiko_import.entity_state not null default 'OK',
problem_since timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index imported_dish_source_uniq
on iiko_import.imported_dish
(restaurant_id, dish_id,
coalesce(size_id, '00000000-0000-0000-0000-000000000000'::uuid));
create index imported_dish_lookup_idx
on iiko_import.imported_dish (restaurant_id, dish_id);
Одна строка — последнее корректное состояние заказываемой позиции iiko с ценой в конкретном
ресторане. Нулевая цена корректна. Если у нового блюда цена null, строка не создаётся. Если такое
произошло с ранее импортированным блюдом, его корректные поля и старая цена сохраняются, а state
становится INVALID. В реальных данных размеры чаще представлены разными dish_id, но nullable
size_id оставлен для настоящих iiko-размеров.
Глобальный модификатор и его вхождение в блюдо¶
create table iiko_import.imported_modifier (
id bigint generated always as identity primary key,
restaurant_id uuid not null references restaurant.restaurant(id),
modifier_id uuid not null,
source_sku text,
price numeric(12,2) not null check (price >= 0),
source_data jsonb not null default '{}',
state iiko_import.entity_state not null default 'OK',
last_seen_run_id bigint not null references iiko_import.import_run(id),
last_updated_by_run_id bigint not null references iiko_import.import_run(id),
problem_since timestamptz,
updated_at timestamptz not null default now(),
unique (restaurant_id, modifier_id)
);
create table iiko_import.imported_dish_modifier (
id bigint generated always as identity primary key,
imported_dish_id bigint not null
references iiko_import.imported_dish(id) on delete cascade,
imported_modifier_id bigint not null
references iiko_import.imported_modifier(id),
group_id uuid,
source_name text not null,
min_quantity numeric(8,3) not null default 0,
max_quantity numeric(8,3) not null default 0,
default_quantity numeric(8,3) not null default 0,
position integer not null default 0,
source_data jsonb not null default '{}',
state iiko_import.entity_state not null default 'OK',
last_seen_run_id bigint not null references iiko_import.import_run(id),
last_updated_by_run_id bigint not null references iiko_import.import_run(id),
problem_since timestamptz,
updated_at timestamptz not null default now(),
unique nulls not distinct (imported_dish_id, group_id, imported_modifier_id)
);
create index imported_modifier_lookup_idx
on iiko_import.imported_dish_modifier (imported_dish_id, imported_modifier_id);
Цена намеренно лежит в imported_modifier, а не во вхождении блюда. Различия 35 ₽ / 60 ₽ из
ранее исследованного снимка клиент признал ошибкой настройки; бизнес-инвариант — одна цена
modifier ID для всех блюд и размеров. Актуальные v2 и v3 содержат одинаковые 236 ID и полностью
совпадающие цены, без расхождений между блюдами ни у одного ID. В v3 21 031 вхождение в блюдах
содержит ссылку на ID без цены. Контекстными остаются группа, порция и ограничения.
Новое вхождение создаётся, а существующее обновляется, если:
global modifier exists and has price != null
AND (max_quantity > 0 OR min_quantity > 0 OR default_quantity > 0)
Если проверка не проходит, существующее вхождение не перезаписывается и получает state = INVALID;
для нового вхождения создаётся только import_issue.
Алгоритм частично успешного импорта¶
Импорт не является заменой снимка целиком:
- Берётся блокировка по
restaurant_idи запоминается выбранныйexternal_menu_id. - Ответ iiko должен быть полностью получен и синтаксически разобран. До этого БД не меняется.
- Каждое блюдо валидируется и сохраняется как отдельный агрегат/транзакция.
- Корректное блюдо и его корректные модификаторы записываются через upsert независимо от остальных. Ошибка самого блюда отменяет обновление всего его агрегата; ошибка одного модификатора не мешает обновить блюдо и остальные корректные модификаторы.
- Некорректная новая сущность пропускается и фиксируется в
import_issue. - Некорректная существующая сущность сохраняет последнее корректное содержимое; меняются только
state,problem_sinceи диагностические ссылки на запуск. - После полного ответа ранее известные, но не встреченные сущности не удаляются: они получают
state = MISSING.MISSINGне выставляется после частичного или оборванного ответа. - Перед каждым commit проверяется, что external menu ресторана не изменилось во время импорта.
Состояния INVALID и MISSING сами по себе не скрывают уже опубликованный товар: витрина продолжает
использовать последнее корректное состояние, а система создаёт инцидент и уведомление. Это защищает
каталог от случайного удаления или повреждения блюда в iiko. Политика длительно не устранённой проблемы
(например, скрыть после нескольких дней) задаётся отдельно.
Полное удаление импортированных строк разрешено только как явная административная операция при ручной смене external menu. Временная пустота каталога в этом редком сценарии допустима.
4. Виртуальный каталог¶
Категории и товары¶
create schema if not exists catalog;
create type catalog.product_status as enum ('DRAFT', 'PUBLISHED', 'ARCHIVED');
create table catalog.category (
id bigint generated always as identity primary key,
parent_id bigint references catalog.category(id) on delete set null,
name text not null,
slug text not null unique,
position integer not null default 0,
enabled boolean not null default true
);
create table catalog.product (
id bigint generated always as identity primary key,
name text not null,
slug text not null unique,
description text,
status catalog.product_status not null default 'DRAFT',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Категории сайта не связаны с категориями iiko. Они создаются и сортируются вручную; товар может лежать
в любом количестве категорий через grid_product_item. Полная модель размещения, включая DDL сетки,
маркетинговые карточки, видео и баннеры, описана в 14-catalog-grid.md.
Варианты товара и привязка к блюдам iiko¶
create table catalog.product_variant (
id bigint generated always as identity primary key,
product_id bigint not null references catalog.product(id) on delete cascade,
name text not null,
code text,
position integer not null default 0,
is_default boolean not null default false,
iiko_dish_id uuid not null,
iiko_size_id uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index product_variant_source_uniq
on catalog.product_variant
(product_id, iiko_dish_id,
coalesce(iiko_size_id, '00000000-0000-0000-0000-000000000000'::uuid));
create index product_variant_source_idx
on catalog.product_variant (iiko_dish_id, iiko_size_id);
Например, один товар сайта «Пепперони» объединяет четыре разных dish_id iiko как варианты 25, 30,
35 и 40 см. Вариант разрешается в выбранном ресторане следующим соединением:
select d.*
from catalog.product_variant v
join iiko_import.imported_dish d
on d.restaurant_id = :restaurant_id
and d.dish_id = v.iiko_dish_id
and d.size_id is not distinct from v.iiko_size_id
where v.id = :variant_id;
Если строки нет, вариант недоступен в этом ресторане. Сам товар и его настройки не удаляются.
Ингредиенты, соусы и топпинги сайта¶
create type catalog.option_kind as enum ('INGREDIENT', 'SAUCE', 'TOPPING', 'OTHER');
create table catalog.option (
id bigint generated always as identity primary key,
kind catalog.option_kind not null,
name text not null,
description text,
image_id bigint,
enabled boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table catalog.option_iiko_binding (
option_id bigint not null references catalog.option(id) on delete cascade,
modifier_id uuid not null,
primary key (option_id, modifier_id),
unique (modifier_id)
);
create table catalog.product_option (
product_id bigint not null references catalog.product(id) on delete cascade,
option_id bigint not null references catalog.option(id) on delete cascade,
position integer not null default 0,
enabled boolean not null default true,
primary key (product_id, option_id)
);
Имя и картинка принадлежат catalog.option; название из iiko нужно только для поиска и предложения
автозаполнения. Доступные покупателю опции являются пересечением:
catalog.product_option
INTERSECT
modifier_id из imported_modifier, связанного с imported_dish_modifier выбранного блюда и ресторана
При показе и заказе цена берётся из глобального imported_modifier, а ограничения и group_id —
из конкретного импортированного вхождения.
Изображения и характеристики¶
create table catalog.product_image (
id bigint generated always as identity 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,
position integer not null default 0,
alt text
);
create table catalog.tag (
id bigint generated always as identity primary key,
name text not null,
slug text not null unique
);
create table catalog.product_tag (
product_id bigint not null references catalog.product(id) on delete cascade,
tag_id bigint not null references catalog.tag(id) on delete cascade,
primary key (product_id, tag_id)
);
При необходимости полноценные характеристики добавляются отдельными таблицами attribute,
attribute_value, product_attribute_value, не меняя импорт iiko.
5. Пример размещения данных¶
restaurant.restaurant
Панам: organization=A, external_menu=41188, terminal=T1
catalog.product
Пепперони
catalog.product_variant
25 см -> dish=D25
30 см -> dish=D30
35 см -> dish=D35
40 см -> dish=D40
iiko_import.imported_dish
restaurant=Панам, dish=D25, price=...
restaurant=Панам, dish=D30, price=...
restaurant=Панам, dish=D35, price=...
restaurant=Панам, dish=D40, price=...
catalog.option
Халапеньо: собственные имя и картинка
catalog.option_iiko_binding
option=Халапеньо -> modifier=M1
iiko_import.imported_dish_modifier
dish=D25, modifier=M1, group=G1, price=60
dish=D30, modifier=M1, group=G1, price=60
У второго ресторана могут быть другое external menu и другие цены. Для него создаётся своя проекция
imported_dish и imported_dish_modifier, а товары, категории, изображения и опции сайта остаются
общими.
6. Данные для заказа¶
Перед созданием заказа сервер повторно проверяет вариант и опции в контексте ресторана. В неизменяемый снимок позиции заказа записываются:
restaurant_id
catalog_product_id
catalog_variant_id
iiko_dish_id
iiko_size_id
unit_price
modifier_id
modifier_group_id
modifier_unit_price
quantity
Заказ после подтверждения не перечитывает UUID и цены из текущего импорта: последующее обновление меню не должно менять уже созданный заказ.
7. Итоговые ключи¶
| Данные | Контекст |
|---|---|
| Цена блюда | restaurant + dish + size |
| Цена модификатора | restaurant + modifier |
| Состав и ограничения модификатора | restaurant + dish + size + group + modifier |
| Маршрут заказа | restaurant -> terminal_group_id |
| Вариант сайта | product_variant -> dish + size без FK на импорт |
| Оформление модификатора | catalog.option -> modifier_id |
external_menu_id не дублируется в ключах импортированных цен, потому что у ресторана активно только
одно меню. При обычном импорте используется поэлементный upsert с сохранением последнего корректного
состояния; очистка выполняется только явно при смене меню. price_category_id в модели отсутствует
полностью.