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 (не дублировать расходящиеся схемы)».
Осложняющее обстоятельство: экран «Цены» умеет сортировать по цене и фильтровать «со скидкой / без скидки». Это операции над всем каталогом, а не над текущей страницей.
Рассмотренные варианты
-
Читать цены из WB на каждый показ страницы.
POST list/goods/filterпринимает список артикулов, то есть страница — один запрос, и цена всегда свежая. Минус, который закрывает вопрос: нельзя сортировать и фильтровать по цене — для этого нужен весь набор, а он у большого продавца это сотни запросов при лимите 10 запросов / 6 с. -
Отдельная таблица
product_prices1:1 кproducts. Плюс: раздельные владельцы записи. Минус: связь один-к-одному по тому же ключу, join на каждый показ каталога и вторая сущность, которую нужно чистить вслед за карточками. -
Снимок цены в колонках
products, наполняемый отдельным синком. Плюс: сортировка и фильтр по цене — обычный SQL по одной таблице; каталог буквально один. Минус: у одной строки два писателя с разной периодичностью. -
Два каталога — свой в модуле цен, свой в репрайсере. Отвергнут прямым требованием постановки: разошлись бы не только поля, но и ответ на вопрос «какие товары вообще есть у кабинета».
Решение
Выбран вариант 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-индекс (отдельное решение, расширение сейчас не включено).
- цена в каталоге — снимок возрастом до 30 минут, а не значение из WB в
момент запроса. Для выбора позиций и обзора это верно; операции записи
(
-
Дальше: журналы прогонов репрайсера и ставок автобиддера (следующий кусок эпика) уже смогут ссылаться на эту номенклатуру.