Перейти к основному содержимому

0052. Единый каталог номенклатуры для цен и репрайсера

Копия ADR из репозитория: backend/docs/adr/0052-shared-product-catalog.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-09-08
  • Связанные задачи: OP-223 (5.2/5.3); опирается на OP-113 (синк каталога), ADR-0020/0022/0023

Контекст и проблема​

Экраны «Цены» и «Репрайсер» не могли работать: POST /pricing/bulk существовал, но выбирать товары было не из чего, а KPI репрайсера («товаров под правилами») не из чего было считать. На фронте оба каталога были заглушками (fetchPriceProducts / fetchRepricerProducts → null).

Постановка отдельно требует: «каталог цен и каталог репрайсера — из одного источника номенклатуры WB (не дублировать расходящиеся схемы)».

Осложняющее обстоятельство: экран «Цены» умеет сортировать по цене и фильтровать «со скидкой / без скидки». Это операции над всем каталогом, а не над текущей страницей.

Рассмотренные варианты​

  1. Читать цены из WB на каждый показ страницы. POST list/goods/filter принимает список артикулов, то есть страница — один запрос, и цена всегда свежая. Минус, который закрывает вопрос: нельзя сортировать и фильтровать по цене — для этого нужен весь набор, а он у большого продавца это сотни запросов при лимите 10 запросов / 6 с.

  2. Отдельная таблица product_prices 1:1 к products. Плюс: раздельные владельцы записи. Минус: связь один-к-одному по тому же ключу, join на каждый показ каталога и вторая сущность, которую нужно чистить вслед за карточками.

  3. Снимок цены в колонках products, наполняемый отдельным синком. Плюс: сортировка и фильтр по цене — обычный SQL по одной таблице; каталог буквально один. Минус: у одной строки два писателя с разной периодичностью.

  4. Два каталога — свой в модуле цен, свой в репрайсере. Отвергнут прямым требованием постановки: разошлись бы не только поля, но и ответ на вопрос «какие товары вообще есть у кабинета».

Решение​

Выбран вариант 3 плюс общий сервис выборки.

Хранение. products расширена снимком цены (price, discounted_price, discount_percent, club_discount_percent, currency, price_synced_at) и превью image_url. Все колонки nullable: карточка приезжает из Content API раньше, чем по ней приедет цена, и товар без цены обязан оставаться видимым.

Два писателя, непересекающиеся множества колонок. Синк карточек (ProductsService.upsert, раз в сутки) пишет номенклатуру и не трогает ценовые колонки; синк цен (upsertPriceSnapshots, раз в 30 минут) пишет только цены. Иначе ежедневный синк карточек затирал бы свежие цены.

Частота. Цены — каждые 30 минут: они меняются постоянно (репрайсер, массовые загрузки, правки продавца в кабинете WB), тогда как название и бренд почти статичны. Реже, чем фиды продаж: каталог не оперативная витрина, а бакет prices делится со всеми записями цен.

Запись пачками. Обновление идёт UPDATE ... FROM unnest(<колоночные массивы>): весь чанк — 8 bind-параметров независимо от числа строк, поэтому лимит Postgres в 65 535 параметров недостижим в принципе (в отличие от VALUES (…), (…), ради которого существует chunked-write). Артикулы без карточки в каталоге молча пропускаются — создавать «товар» без названия и бренда нельзя, он испортил бы выдачу.

Одна выборка на два экрана. ProductCatalogService.list() — единственное место, где живут поиск, фильтры, сортировка и пагинация каталога. Экраны надстраивают своё поверх результата: цены — ничего, репрайсер — привязку правила. На уровне контракта RepricerProductDto extends CatalogProductDto, и это проверяется тестом (openapi.e2e-spec.ts): своими у репрайсера могут быть только ruleId/ruleName/minPrice/maxPrice.

Одна семантика привязки правил. matchesRuleScope вынесена из движка в repricer-scope.util.ts; её используют и прогон, и каталог. Расхождение «показали одно правило, применили другое» — самый неприятный класс ошибок здесь. Каталог при этом не оценивает условие правила: он отвечает на вопрос «какое правило закреплено», а не «сработает ли оно сейчас».

Последствия​

  • Положительные:

    • оба экрана разблокированы: GET /pricing/products и GET /repricer/products;
    • сортировка по цене и фильтр по скидке идут по всему каталогу, meta.total честный;
    • NULLS LAST + тай-брейк по nm_id: товары без цены не занимают первые места, строки не прыгают между страницами;
    • расхождение схем двух каталогов теперь невозможно молча — его ловит Gate 1.
  • Отрицательные / компромиссы:

    • цена в каталоге — снимок возрастом до 30 минут, а не значение из WB в момент запроса. Для выбора позиций и обзора это верно; операции записи (POST /pricing/bulk) по-прежнему идут в WB напрямую и снимком не ограничены;
    • у товаров с editableSizePrice: true цены размеров различаются, а строка каталога одна. Показываем минимальную цену среди размеров — то, что покупатель видит на витрине как «от», и то, с чем осмысленно сравнивать пороги репрайсера. Точные цены по размерам — отдельный метод WB (list/goods/size/nm) и отдельный экран, если понадобится;
    • поиск ILIKE '%…%' по названию и артикулу индекс не использует. На типовом кабинете это приемлемо; если профилирование покажет проблему — pg_trgm + GIN-индекс (отдельное решение, расширение сейчас не включено).
  • Дальше: журналы прогонов репрайсера и ставок автобиддера (следующий кусок эпика) уже смогут ссылаться на эту номенклатуру.