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

0030. Правила объединения данных РНП и модель витрины по артикулу

Копия ADR из репозитория: backend/docs/adr/0030-rnp-data-merge-rules.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted (согласовано 2026-06-29 — приняты рекомендованные ответы на 4 вопроса: финансы по дню заказа, день в МСК, только дневная витрина с агрегацией на лету, состав метрик ниже)
  • Дата: 2026-06-29
  • Связанные задачи: OP-86 (Spike · 5.6 · РНП), потребитель — OP-82 (витрина), OP-83 (API)
  • Связанные ADR: ADR-0008 (хранение временных рядов), ADR-0009 (реестр расписаний); реализуется поверх OP-91 (партиции) и OP-92 (re-aggregate *_daily)

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

ТЗ 5.6 прямо помечает «структура хранения данных» и «правила объединения данных из разных источников» как уточняется. К моменту спайка все источники РНП уже собираются в raw fact-таблицы по артикулу (nm_id):

ИсточникТаблицаКлюч идемпотентностиПериод
Заказы (OP-54/79)wb_orders(wb_account_id, srid)date (timestamptz)
Продажи/возвраты (OP-54/79)wb_sales(wb_account_id, sale_id)date (timestamptz)
Реклама (OP-65/80)ad_campaign_stats(advert_id, wb_account_id, date, nm_id)date
Финансы: комиссии/логистика/штрафы (OP-55)wb_report_lines(wb_account_id, rrd_id)период отчёта (rr_dt)
Воронка/конверсии (OP-78)rnp_funnel_stats(wb_account_id, nm_id, date)date

Нужно: единый ключ сопоставления, правила разрешения дублей/конфликтов, гранулярность периодов и итоговая схема витрины — чтобы OP-82 строил её детерминированно.

Предлагаемое решение​

1. Ключ сопоставления — (wb_account_id, nm_id, day)​

  • nm_id (артикул WB) присутствует во всех источниках и стабилен — базовая размерность. Бренд/магазин/списки накладываются поверх через справочники (OP-38), не входят в ключ.
  • day — календарный день в московском времени (WB statistics/analytics ведут учёт по МСК; rnp_funnel_stats/ad_campaign_stats уже в днях МСК). wb_orders/wb_sales (timestamptz) приводятся к дню МСК.

2. Трёхслойная модель (поверх ADR-0008)​

  1. Raw fact-таблицы (как есть, по источникам) — не меняются.
  2. Дневные роллапы <domain>_daily (механизм OP-92 re-aggregate): на источник — идемпотентный агрегат по (wb_account_id, nm_id, day) (sales_daily, ads_daily, finance_daily, funnel_daily). Партиционируются помесячно (OP-91), пересчитываются по событию late-arriving (OP-92).
  3. Витрина rnp_daily — FULL/LEFT JOIN всех <domain>_daily по (wb_account_id, nm_id, day) в одну строку. Отсутствие источника за день → нули (домен не прислал данных), а не пропуск строки.

3. Правила объединения и разрешения конфликтов​

  • Дубли — сняты на уровне raw идемпотентным upsert (уникальные ключи выше); дневной роллап детерминирован (SUM количеств/сумм, конверсии — из воронки как есть).
  • Late-arriving — re-aggregate (OP-92) пересчитывает затронутый (account, nm_id, day); витрина пересобирается из *_daily идемпотентно.
  • Привязка финансов к периоду (ключевое правило, на согласование). wb_report_lines имеют период отчёта реализации, а не день заказа. Предлагается привязывать затраты к дню исходного заказа (srid → заказ → nm_id + день заказа), чтобы расходы стояли в одной строке с продажами/рекламой/воронкой того же дня. Альтернатива — день отчёта (проще, но рассинхрон с воронкой/рекламой). Рекомендация: день заказа.
  • Бренд — из любого источника с непустым brand/brand_id (nm_id → один бренд, конфликт маловероятен; при расхождении — приоритет wb_report_lines > wb_sales > каталог OP-38).
  • Валюта — рубли (WB отдаёт RUB; currency-поля проверяются, не-RUB логируется и пропускается до отдельного решения по мультивалютности).

4. Схема витрины rnp_daily (предлагаемая)​

Ключ: wb_account_id, nm_id, day, brand. Метрики (нули при отсутствии источника):

  • Продажи: orders_count, orders_sum, buyouts_count, buyouts_sum, returns_count, returns_sum.
  • Реклама: ad_views, ad_clicks, ad_spend, ad_orders.
  • Финансы: commission, logistics, penalties, to_pay (к перечислению).
  • Воронка: open_card_count, add_to_cart_count, add_to_cart_conversion, cart_to_order_conversion, buyout_percent.
  • Служебное: recomputed_at.

Производные (ДРР = ad_spend/orders_sum, маржа, CTR, CR) — не хранятся, считаются в API аналитики (OP-83) на лету / в агрегатах за период. Партиционирование rnp_daily — помесячно по day (OP-91); агрегация в неделю/месяц — поверх (on-the-fly в OP-83, материализация недельных роллапов — отдельный тикет при потребности).

Вопросы на согласование (заказчик/руководитель)​

  1. Привязка финансов: день заказа (srid) — рекомендуется — vs день отчёта реализации.
  2. Часовой пояс дня: МСК — рекомендуется.
  3. Гранулярность хранения: только дневная rnp_daily + агрегация за период на лету (рекомендуется) vs материализовать недельные/месячные роллапы сразу.
  4. Состав метрик витрины — достаточен ли список выше для отчётов 5.6, или добавить (напр. остатки/оборачиваемость из раздела «Остатки», упущенные продажи).

Последствия / готовность к OP-82​

  • Реализация витрины (OP-82) = per-domain re-aggregate-роллаперы (контракт OP-92) → *_daily → ETL объединения в rnp_daily; всё идемпотентно и пересчитываемо.
  • Решение согласовано (2026-06-29): приняты рекомендованные ответы на все 4 вопроса. OP-82 реализует схему витрины один-в-один по этому ADR.

Реализация (OP-82) — уточнения и ограничения​

Реализовано в модуле rnp: роллаперы rnp-sales/rnp-ads/rnp-finance (ReAggregateHandler, OP-92) → sales_daily/ads_daily/finance_daily, merge rnp → rnp_daily; плановый ETL rnp-merge-sync пересчитывает окно последних дней.

  • funnel_daily отдельной таблицей не заводится — её роль исполняет существующая rnp_funnel_stats (OP-78), уже дневной агрегат по (account, nm_id, date) от WB Analytics. Merge читает воронку прямо из неё.
  • Ключ — составной PK (wb_account_id, nm_id, day) (без brand): PostgreSQL требует колонку партиции (day) в каждом UNIQUE/PK и не допускает NULL в PK, а brand — производный атрибут (одна nm_id → один бренд). Бренд хранится как обычная колонка.
  • Идемпотентность включает удаления: пересчёт дня заменяет строки роллапа транзакцией DELETE (account, day) + INSERT, иначе выбывший из источника артикул (например, единственный заказ дня отменён) завис бы устаревшим агрегатом.
  • Бутстрап партиций (миграция) пред-создаёт прошлый + текущий + 3 будущих месяца — окно пересчёта ETL заходит в прошлый месяц в его первые дни, а менеджер OP-91 создаёт партиции только вперёд.

Известные ограничения (приняты осознанно):

  • Строки wb_report_lines без сопоставимого заказа и без rr_dt (или с заказом, у которого date IS NULL) в витрину не попадают — крайне редкий случай битых дат.
  • Артикул, у которого за день была только реклама (нет продаж/финансов/воронки), получает brand = NULL (в ad_campaign_stats бренда нет) — бренд добирается из других дней/каталога (OP-38).
  • Дедуп merge-джобы по jobId (OP-92): при concurrency воркера > 1 merge может снять снапшот *_daily до завершения последнего домена; расхождение сходится следующим событием/прогоном ETL (документированное поведение OP-92, не специфично для OP-82).
  • Дневная агрегация выполняется в приложении (чистые функции — ради тестируемости на эталонных наборах, AC OP-90); объёмы ограничены одним (кабинет, день) на джобу.