BR 1.17 — Привязка опций structural-модификатора к 1С

Зависит от:

Контекст

В 1С Общепит каждая вариация блюда — отдельная номенклатура с уникальным кодом:

1С-кодНазвание в 1СЦена
00000001234Кофе Капучино 250 гр.порц.220 ₽
00000001235Кофе Капучино макси 300 гр.порц.250 ₽
00000001236Кофе Капучино макси 400 гр.порц.280 ₽

Бухгалтер ведёт техкарты на каждый размер отдельно, сверяет чеки с этими кодами, делает декларации и отчётность.

В нашем POS такие вариации лучше показать одной плиткой «Капучино» с выбором размера через structural-модификатор (см. BR 1.9.2). Меньше плиток на экране, меньше ошибок ввода кассира.

Узкое горлышко — связать две картины: на нашей стороне один продукт + модификатор, на 1С-стороне — три отдельных кода. У modifier_options сейчас НЕТ поля для 1С-кода. Без этого выгрузка чеков в 1С теряет детализацию.

Эта BR — фундамент для выгрузки чеков в 1С

Сама логика выгрузки описана отдельной BR (предлагается ветка «Интеграции с 1С Общепит»). Здесь — только данные в каталоге.

Решение (минимальный скоуп)

Одно новое поле: modifier_options.sku_1c — код номенклатуры 1С для опции structural-модификатора.

При выгрузке отчёта в 1С: если у позиции выбрана structural-опция с sku_1c — используется он. Если у продукта нет structural-модификатора — используется существующее поле products.sku (в которое мы уже пишем '1c:КОД').

Что НЕ добавляем (намеренно, чтобы минимизировать скоуп):

  • products.sku_1c — у products.sku уже хранится '1c:КОД', работает на cifra1/cifra2. Переименование = гигиена без функции.
  • modifier_options.name_1c — оригинальное имя 1С можно класть в существующее modifier_options.description (уже используется как костыль в cifra2). Имя при выгрузке берётся в 1С на её стороне по коду — нам имя не нужно для матчинга.

Требования

1. Расширение схемы

catalog.modifier_options — добавить одно поле:

ПолеТипNullableОписание
sku_1cvarchar(50)даКод 1С-номенклатуры. Уникален в рамках franchise_id (если задан). Используется при выгрузке отчётов в 1С.

Также: deleted_at (timestamp, nullable) — если ещё не существует, добавляется. Нужно для soft-delete опций при сохранении исторических заказов (см. ниже).

2. Правила валидации

При создании/обновлении продукта в catalog-service:

  • Простой продукт (без structural-модификатора): products.sku остаётся как есть (значение '1c:КОД' или штрихкод/артикул).
  • Виртуальный продукт (хочет быть привязанным к 1С через опции): должен иметь ровно один structural-модификатор, у всех опций которого заполнен sku_1c.
  • Free-модификаторы: sku_1c опционально (поле есть впрок для будущего, но валидация не требует — в этой BR не используется).

Ограничение «один structural-мод на продукт» — критично. Алгоритм разрешения 1С-позиции (см. §3) однозначен только если structural-мод один. Многомерные сценарии решаются через «N по M» виртуальных продуктов (см. §4).

Где запретить второй structural — backend или только UI?

При реализации проверить состояние order-service / catalog-service на момент работы:

  • Если бэкенд уже принимает несколько structural-модификаторов на продукт (например — продукт «Пицца» с «Размер» + «Тесто» из тестовых fixtures), и в order_modifier_entries это уже сохраняется корректно — резать только на фронте (admin-web показывает 1, бэкенд принимает N). Это снимает риск миграции существующих данных.
  • Если бэкенд не готов — добавляем валидацию на бэк-стороне.
  • Аудит-запрос перед стартом: SELECT product_id, COUNT(*) FROM product_modifiers WHERE binding_type='structural' GROUP BY product_id HAVING COUNT(*) > 1 — найти проблемные продукты в текущей БД.

Ошибки:

  • STRUCTURAL_OPTION_MISSING_SKU_1C — у одной из опций structural-мода пустой sku_1c.
  • MULTIPLE_STRUCTURAL_MODIFIERS_NOT_ALLOWED — попытка добавить второй structural-модификатор к продукту (если решено валидировать на бэке).

3. Логика разрешения 1С-позиции для чека

Псевдокод (для будущей выгрузки — реализуется в отдельной BR):

def resolve_1c_position(order_item):
    structural_option = order_item.get_structural_option()
    if structural_option and structural_option.sku_1c:
        return structural_option.sku_1c
    if order_item.product.sku and order_item.product.sku.startswith('1c:'):
        return order_item.product.sku[3:]  # отрезать префикс '1c:'
    raise CannotMapTo1C(order_item.product.id)

Snapshot vs lookup

При выгрузке исторических чеков актуальные значения sku_1c могут отличаться от тех, что были на момент продажи (бухгалтер перепривязал). В этой BR используется lookup (текущее значение). Если потребуется аудит-точность — отдельная BR «Snapshot 1С-кодов в order_items».

4. Подход к многомерным модификаторам — «N по M»

Если в 1С продукт имеет несколько измерений (размер × молоко × сироп), и каждая комбинация — отдельная номенклатура в 1С:

1С:
  Капучино обычный 250 → код 001
  Капучино обычный 300 → код 002
  Капучино обычный 400 → код 003
  Капучино соевый 250  → код 004
  Капучино соевый 300  → код 005
  Капучино соевый 400  → код 006
  Капучино овсяный 250 → код 007
  Капучино овсяный 300 → код 008
  Капучино овсяный 400 → код 009

Pattern в нашей ERP — отражение структуры 1С:

Виртуальный продуктStructural «Объём» — опции
Капучино обычный250 → 001, 300 → 002, 400 → 003
Капучино соевый250 → 004, 300 → 005, 400 → 006
Капучино овсяный250 → 007, 300 → 008, 400 → 009

3 виртуальных продукта × 3 опции каждый = 9 sku_1c, покрывают все комбинации.

Это полностью покрывается данной BR без новых полей или таблиц. Импорт-скрипт автоматически группирует по «корню имени» — если в 1С позиции названы Кофе Капучино обычный 250, Кофе Капучино обычный 300 — корень Кофе Капучино обычный, образуется один виртуальный продукт.

POS UX компенсация (1 плитка вместо N) — отдельная задача через подкатегории или display_group. Не часть BR 1.17.

Настоящая матрица (отдельная таблица product_combinations с массивом option_ids) — overkill. В реальной 1С такого почти не встречается, потому что у каждой комбинации обязана быть своя техкарта → отдельная позиция. В этой BR не реализуется. При появлении конкретного запроса — отдельная BR.

5. UI

Карточка structural-модификатора (admin-web)

В таблице опций — одна новая колонка: «Код 1С (sku_1c)». Inline-редактирование.

Состояние карточки:

  • Если хотя бы одна опция без sku_1c → красное предупреждение «Не все опции привязаны к 1С — выгрузка не сработает».
  • Иконка-подсказка: «Где взять код 1С? — В справочнике номенклатуры 1С, колонка “Код”».

Карточка продукта (admin-web)

Без новой секции «Связь с 1С» — продукт показывает свой статус привязки через состояние модификатора:

  • Простой (без modifier’ов) — sku отображается как сейчас.
  • Виртуальный (с structural-modifier) — добавляется индикатор: ✅ «Все опции привязаны к 1С» / ⚠️ «Не все опции имеют sku_1c».

POS Desktop

Без изменений. Текущая логика модификаторов уже работает (см. реализацию «Бургер + Соус» в BR 1.8). Базовая цена «материнского» продукта = 0 ₽, цена опции = полная цена варианта.

Кассир видит:

  • Плитка «Капучино» (без цены или «от 220 ₽»).
  • Тап → модалка с тремя кнопками: 250 мл 220₽, 300 мл 250₽, 400 мл 280₽.
  • Выбор → конкретная позиция в корзине с правильной ценой.

6. Soft-delete опций

При удалении опции из админки:

  • Запись в modifier_options остаётся (помечается deleted_at = now()).
  • В POS-меню и в админке опция не отображается.
  • При выгрузке исторических заказов (где order_modifier_entries.modifier_option_id указывает на удалённую опцию) — sku_1c всё ещё читается корректно.

Если поле deleted_at ещё не существует в modifier_options — добавить в миграции. Если уже есть — использовать как есть.

7. Изменение скрипта импорта scripts/1c-import/import-1c-orp-grouped.py

Скрипт уже сейчас делает группировку по «корню имени без размера» — этот режим включён по умолчанию. После реализации BR 1.17 надо:

  • Переместить 1С-код опции из description (где он сейчас как костыль '1c:КОД | оригинал') → в явное поле sku_1c.
  • Для простых продуктов оставить sku = '1c:КОД' как сейчас (без изменений).
  • Сохранить алгоритм извлечения корня:
    • Pattern: \s+(макси\s+)?(\d+)\s*(мл|г|гр)\b\.?\s*$ — захватывает trailing размер.
    • Корень = имя минус trailing размер.
    • Минимум 2 позиции с одинаковым корнем → группа.

Затронутые сервисы

СервисИзменения
catalog-serviceLiquibase миграция (1 колонка + soft-delete если нет), Entity/DTO, валидация «один structural + опции с sku_1c»
admin-bffПроксирование sku_1c в /api/v1/catalog/modifier-options
admin-webКолонка sku_1c в карточке structural-модификатора, индикатор состояния на продукте
order-serviceБез изменений (modifier_option_id уже сохраняется в order_modifier_entries)
scripts/1c-import/Миграция description-костыля в явное поле sku_1c
pos-desktopБез изменений

Что НЕ входит в эту BR

  • Логика выгрузки чеков в 1С — отдельная BR из ветки «Интеграции с 1С Общепит».
  • products.sku_1c — уже работает через products.sku с префиксом '1c:'. Гигиенический рефакторинг — отдельная задача (не функция).
  • *.name_1c — оригинальные имена 1С хранятся в description (если нужны для аудита). Для выгрузки имени не требуются — матчинг по коду.
  • Free-модификаторы с sku_1c — поле опционально, логика отдельной BR (например для случая «Молоко соевое доплата» как отдельная номенклатура в 1С).
  • Многомерные модификаторы через матрицу — решаются паттерном «N по M» (см. §4).
  • Snapshot 1С-кодов в order_items — для аудита исторических заказов. Отдельная BR при появлении запроса.
  • Bidirectional синхронизация с 1С — не предмет этой BR.
  • Управление акциями и скидками — отдельная BR. Сейчас в 1С скидки видны как разные цены под одним кодом; в нашей ERP это будет обрабатываться через pos.discount.apply permission + ручную правку цены кассиром.

Известные ограничения

  1. Двойные цены под одним 1С-кодом в чеках (Шаурма 198/242, Капучино 250/310) — не покрываются BR 1.17. Это акции/скидки, не разные позиции. Решение — отдельная BR по управлению акциями. В рамках 1.17 берётся модальная цена как стандартная.

  2. Аверс vs наш POS — синхронизация акций. Сейчас все акции живут в Аверсе. После перехода на наш POS их нужно либо вручную пересоздать в нашей админке, либо кассиры будут применять руками. Не предмет 1.17.

  3. Бухгалтер удалит позицию в 1С. Тогда наш sku_1c устареет → выгрузка пробьётся в deprecated-позицию. Решение: админ обновляет sku_1c в карточке опции. Автоматического sync нет (это другая BR).

  4. Множественные внешние системы. Если в будущем появится интеграция с СБИС/1С Розница/PayKeeper-каталогом — у нас будет sku_1c + sku_sbis + … Это анти-паттерн, лучше будущая external_ids-таблица. В рамках 1.17 фиксируем под 1С специфически.

  5. Конфликт с PayKeeper Catalog Sync (BR 3.4). В наших цифра1/цифра2 импортах мы пишем в products.sku значение '1c:КОД'. Если PayKeeper Catalog Sync читает products.sku для своего маппинга — возможен конфликт (PK ожидает свой формат, а получает 1С-код).

Проверить при реализации BR 1.17

  • В каком поле PaykeeperAdapter хранит маппинг с нашими продуктами (BR 3.4)? Через sku, отдельную таблицу paykeeper_catalog_items, или через product_id напрямую?
  • Если через sku — нужно либо мигрировать наш формат '1c:КОД' в отдельное поле (введём products.sku_1c тогда уже), либо рефакторить PK на использование product_id / нового поля.
  • Если через отдельную таблицу / product_id — конфликта нет, sku свободен под '1c:КОД'.
  • Скорее всего PK не знает о модификаторах, поэтому modifier_options.sku_1c для PK безопасно в любом случае. Сверять файл erp-paykeeper-adapter/.../CatalogEventConsumer.java и таблицы PK-БД на момент реализации.

Verification

  1. Применить миграцию catalog-service — колонка sku_1c появилась в modifier_options. Также deleted_at если не было.

  2. Через админку:

    • Открыть карточку structural-модификатора «Объём» у Капучино → видны 3 опции с колонкой sku_1c.
    • У одной опции стереть sku_1c → попытка сохранить → ошибка STRUCTURAL_OPTION_MISSING_SKU_1C.
    • Восстановить → сохраняется.
    • Попытка добавить второй structural-мод к Капучино → ошибка MULTIPLE_STRUCTURAL_MODIFIERS_NOT_ALLOWED.
  3. Перезалить cifra2 через обновлённый скриптdescription опций пустеют, sku_1c заполнены. Проверить SQL-запросом:

    SELECT mo.name, mo.sku_1c, pmi.price
    FROM modifier_options mo
    JOIN modifier_groups mg ON mg.id = mo.modifier_group_id
    LEFT JOIN price_list_modifier_items pmi ON pmi.modifier_option_id = mo.id
    WHERE mg.franchise_id = 'c2f0a000-0000-0000-0000-000000000001'
    ORDER BY mg.name, mo.display_order;
  4. На POS Desktop (cifra2@nirbi.ru) — без визуальных изменений по сравнению с предыдущим состоянием. Каталог рендерится так же.

  5. Soft-delete тест: удалить опцию «Капучино 250 мл» из админки → она исчезает из POS-меню, но запись остаётся в БД с deleted_at → если в order_modifier_entries есть исторические ссылки — sku_1c всё ещё доступен через join.

Связь с другими работами

  • Пилот Кофейни Цифра (cifra2) уже работает через костыль description — после реализации BR 1.17 миграция скриптом без даунтайма.
  • BR закладывает фундамент для будущей BR «Excel-выгрузка продаж в 1С Общепит» (предположительно 3.6).
  • Подход «virtual product + structural options с external_id» универсален — работает для любой бухгалтерской системы (1С Розница, СБИС, etc.). При появлении второй интеграции — рефакторинг в external_ids-таблицу (отдельная архитектурная BR).