Перейти к содержанию

Целевая схема БД каталога

Статус: рабочая схема новой модели. Она заменяет прежнюю детализацию каталога и импорта там, где решения расходятся.

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.

Алгоритм частично успешного импорта

Импорт не является заменой снимка целиком:

  1. Берётся блокировка по restaurant_id и запоминается выбранный external_menu_id.
  2. Ответ iiko должен быть полностью получен и синтаксически разобран. До этого БД не меняется.
  3. Каждое блюдо валидируется и сохраняется как отдельный агрегат/транзакция.
  4. Корректное блюдо и его корректные модификаторы записываются через upsert независимо от остальных. Ошибка самого блюда отменяет обновление всего его агрегата; ошибка одного модификатора не мешает обновить блюдо и остальные корректные модификаторы.
  5. Некорректная новая сущность пропускается и фиксируется в import_issue.
  6. Некорректная существующая сущность сохраняет последнее корректное содержимое; меняются только state, problem_since и диагностические ссылки на запуск.
  7. После полного ответа ранее известные, но не встреченные сущности не удаляются: они получают state = MISSING. MISSING не выставляется после частичного или оборванного ответа.
  8. Перед каждым 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 в модели отсутствует полностью.