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

0054. Управление кампанией per-РК и журнал изменений ставок

Копия ADR из репозитория: backend/docs/adr/0054-autobidder-campaign-control-and-bid-journal.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-09-18
  • Связанные задачи: OP-224 (5.4); опирается на OP-65/66/67/68 (ADR-0025), ADR-0021 (журнал цен как образец), ADR-0012 (журнал аудита)

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

Таблица рекламных кампаний на фронте умела только читать аналитику. Из UI нельзя было:

  • включить автоуправление ставками для конкретной РК — стратегия привязывалась «с другой стороны», массивом advert_ids внутри самой стратегии;
  • задать суточный бюджет в строке кампании;
  • узнать, почему ставка изменилась: аудит (OP-45) пишет по прогону одну запись со счётчиками applied/skipped, а поартикульная детализация не сохранялась нигде.

Последнее — не косметика: «ставка не растёт» и «ставка упёрлась в максимум стратегии» выглядят на экране одинаково, и различить их можно было только по логам сервера.

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

Журнал ставок​

  1. Читать из audit_logs. Плюс: таблица уже есть. Минусы: там лежат счётчики, а не значения; чтобы получить строки журнала, пришлось бы писать детализацию в metadata (JSONB) и оттуда же пагинировать и фильтровать по кампании — фильтр по JSONB внутри чужой по смыслу таблицы, без индекса. Аудит отвечает на вопрос «кто что запустил», а не «какое значение на какое изменилось».
  2. Отдельный append-only журнал ad_bid_changes. Ровно то же разделение, что уже принято для цен (ADR-0021): аудит действия + журнал значений.

Бюджет per-кампания​

  1. Новая колонка daily_budget в ad_campaigns. Минус, закрывающий вопрос: суточный лимит уже живёт в ad_campaign_budgets.daily_limit_rub (OP-68) вместе с действием при достижении. Две колонки с одним смыслом разойдутся — вопрос только когда.
  2. Писать в существующую модель бюджетов.

Привязка стратегии​

  1. Только обратная (bid_strategies.advert_ids). Существующий механизм, но UI-действие «выбрать стратегию в строке РК» через него выражается как «прочитать массив чужой сущности, вставить/удалить элемент» — гонка при параллельном редактировании двух кампаний.
  2. Прямая (ad_campaigns.strategy_id) с явным приоритетом над обратной.

Решение​

Выбраны варианты 2, 4 и 6.

PATCH /autobidder/campaigns/:campaignId — campaignId это advertId WB (тот же идентификатор, что отдаёт GET /autobidder/analytics/campaigns). Он уникален только внутри кабинета, поэтому wbAccountId передаётся в теле: выводить его из пути было бы нечем, а угадывание кабинета по advertId открыло бы чтение чужих кампаний.

Выключатель auto_enabled. Движок теперь берёт в работу только кампании с auto_enabled = true. Колонка по умолчанию false: кампания приезжает синком из WB, и начинать в ней торговать без решения продавца нельзя.

Чтобы это не выключило уже работающий автобиддер, миграция бэкфиллит true ровно тем кампаниям, которыми движок управлял и раньше — покрытым активной стратегией (явно через advert_ids либо неявно, когда список пуст). Поведение существующих кабинетов не меняется; меняется поведение новых кампаний — их нужно включить явно, и это заметное для фронта изменение.

Приоритет привязок. Прямая сильнее обратной: кампания, закреплённая за стратегией A, не управляется стратегией B, даже если B перечислила её в своём advert_ids. Иначе две стратегии торговали бы в одной РК по разным целям, и результат зависел бы от порядка строк в выборке. Кампании без прямой привязки работают как раньше.

Журнал ad_bid_changes — append-only, одна строка на изменённую ставку артикула: прежнее и новое значение в копейках (как во всём модуле и в WB API), стратегия, причина. Пишется после успешной отправки в WB — ставка, которую WB не принял, в историю не попадает. Запись best-effort: ставки уже применены, и падать на журналировании нельзя.

Причина — готовый русский текст, а не код: она показывается в ячейке таблицы и отвечает сразу на два вопроса — какая цель сработала и упёрлась ли ставка в границу стратегии (applyStrategyLimits возвращает сработавшую границу именно ради этого).

Имена денормализованы (campaign_name, strategy_name): кампании переименовываются на стороне WB, стратегии мягко удаляются, а журнал обязан показывать состояние на момент изменения. FK на bid_strategies в журнале нет.

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

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

    • строка таблицы РК стала управляемой: выключатель, бюджет, стратегия;
    • «почему ставка такая» отвечается из UI, без логов сервера;
    • бюджет остался в одном месте — расхождение двух источников невозможно;
    • две стратегии больше не могут молча конкурировать за одну РК.
  • Отрицательные / компромиссы:

    • новые кампании приезжают выключенными. Это осознанно (движок не должен начинать торговать сам), но означает шаг «включить» после каждого появления РК — фронту стоит это показывать;
    • журнал растёт линейно по числу изменений ставок. Движок ходит по высокочастотному расписанию (1–2 мин, OP-70), поэтому ретеншн журнала — следующий вопрос: партиционирование по месяцам (как inventory_movements, OP-91) либо TTL-чистка;
    • ставки в журнале в копейках, а KPI-лента в рублях. Единицы разведены по именам полей (oldBidKopecks против spend) — иначе ошибка в 100 раз прошла бы незамеченной;
    • advertId как идентификатор в пути привязывает контракт к нумерации WB. Альтернатива (наш uuid) потребовала бы отдавать его в аналитике, которой он не нужен.
  • Дальше: ретеншн журнала ставок; отражение auto_enabled в UI-онбординге новых кампаний.