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

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';

Как это работает при пропаже позиции.

  1. Импорт помечает iiko.item.deleted_at. Ничего в catalog не удаляется.
  2. Слушатель в модуле catalog переводит variant_binding.state = MISSING, ставит missing_since и создаёт инцидент ITEM_MISSING.
  3. Порог «мигания»: если позиция вернулась в течение binding.missing_grace (по умолчанию 2 импорта или 30 минут), инцидент закрывается автоматически с резолюцией ITEM_RETURNED, состояние возвращается в OK. Это отсекает шум от частичных выгрузок iiko.
  4. Поиск замены: среди позиций, появившихся после пропажи, ищутся кандидаты по совпадению sku (сильный сигнал), затем по нормализованному названию + учётной группе. Найденные попадают в details.candidates и порождают инцидент POSSIBLE_REPLACEMENT.
  5. Перепривязка одним действием: POST /variants/{id}/rebind меняет iiko_item_size_id, дописывает старый id в previous_item_ids, ставит state = REBOUND, закрывает инциденты. Товар, картинки, тексты, SEO, позиция в категории — не затрагиваются.
  6. Если живых вариантов не осталось — товар снимается с публикации, с причиной и уведомлением. При появлении замены и перепривязке публикация восстанавливается в один клик.

Дополнительно 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 = PUBLISHEDcatalog.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 (случайное удаление блюда) не должна стоить контент-менеджеру повторной сборки карточки.