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, а поартикульная детализация не сохранялась нигде.
Последнее — не косметика: «ставка не растёт» и «ставка упёрлась в максимум стратегии» выглядят на экране одинаково, и различить их можно было только по логам сервера.
Рассмотренные варианты
Журнал ставок
- Читать из
audit_logs. Плюс: таблица уже есть. Минусы: там лежат счётчики, а не значения; чтобы получить строки журнала, пришлось бы писать детализацию вmetadata(JSONB) и оттуда же пагинировать и фильтровать по кампании — фильтр по JSONB внутри чужой по смыслу таблицы, без индекса. Аудит отвечает на вопрос «кто что запустил», а не «какое значение на какое изменилось». - Отдельный append-only журнал
ad_bid_changes. Ровно то же разделение, что уже принято для цен (ADR-0021): аудит действия + журнал значений.
Бюджет per-кампания
- Новая колонка
daily_budgetвad_campaigns. Минус, закрывающий вопрос: суточный лимит уже живёт вad_campaign_budgets.daily_limit_rub(OP-68) вместе с действием при достижении. Две колонки с одним смыслом разойдутся — вопрос только когда. - Писать в существующую модель бюджетов.
Привязка стратегии
- Только обратная (
bid_strategies.advert_ids). Существующий механизм, но UI-действие «выбрать стратегию в строке РК» через него выражается как «прочитать массив чужой сущности, вставить/удалить элемент» — гонка при параллельном редактировании двух кампаний. - Прямая (
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-онбординге новых кампаний.