Commit Graph

15 Commits

Author SHA1 Message Date
Дмитрий 9488075076 fix реклама: минимум площадки считается по длине периода, а созданные объявления сами уходят на модерацию
Живой запуск кампании на бою 30.07.2026 вскрыл две поломки, которых вчерашняя
починка не видела. Обе — из класса молчаливых: портал рапортует успех, а в жизни
ничего не происходит.

ПЕРВАЯ. Минимум Директа НЕ постоянный.

Вчера мы приняли живой отказ «Budget for this period cannot be less than 600 rub.»
за постоянный порог и зашили 600 ₽ в настройку. Сегодня та же кампания на неделю
получила отказ «cannot be less than 2400 rub.» — проверка её пропустила, и клиент
снова увидел английский текст.

Две точки дали правило: минимум = 300 ₽ за каждый календарный день периода,
считая оба края.
   период 30.07-31.07, 2 дня → 600 ₽  = 2 × 300
   период 30.07-06.08, 8 дней → 2400 ₽ = 8 × 300

Теперь порог считается от периода показа, а ставка за день вынесена в настройку
YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY. Период вычисляется ДО денег — иначе считать
минимум не от чего.

ВТОРАЯ. Создать объявления — не значит запустить рекламу.

Запуск проходил успешно, портал ставил статус «на модерации», а в кабинете лежали
кампания, группа и 15 объявлений в состоянии DRAFT и OFF. Яндекс кладёт всё
созданное черновиком и проверку сам не начинает. Реклама не показалась бы никогда,
и узнать об этом можно было только глазами в кабинете: наш журнал говорил, что всё
хорошо.

Добавлены два вызова, которых не было вовсе:
   ads.moderate     — отдать созданные объявления на проверку
   campaigns.resume — включить показ

Порядок обязателен. Пока кампания черновик, включить её нельзя — Директ отвечает
«кампания является черновиком и не может быть остановлена». Сначала модерация,
она переводит кампанию в MODERATION, и только потом включение.

Отбор объявлений строго по Ids. На CampaignIds Директ отвечает «отсутствует
обязательный параметр Ids» — именно так отправка молча не сработала бы.

На модерацию уходят объявления, созданные ИМЕННО этим заходом: повторная отправка
уже проверяемого — отказ по позиции, он порвал бы возобновляемый запуск. Включение
показа безобидно при любом повторе: на включённой кампании Яндекс отвечает
предупреждением, а не отказом.

Заодно клиент Директа перестал молчать про отказы внутри ответа: раньше он смотрел
только AddResults, UpdateResults и DeleteResults, теперь ещё ModerateResults и
ResumeResults. Отказ по позиции в этих двух проходил бы насквозь незамеченным.

ПРОВЕРЕНО ЖИВЬЁМ. Кампания № 6 на бою: в кабинете 713175197, группа 5778555449,
15 объявлений, аудитория подключена, статус MODERATION, показ включён. На счету
Яндекса 8775 ₽. У клиента заморожено 3333.36 ₽ за 27778 показов до 06.08.

Тесты: 346 зелёных по рекламе, 43 по клиенту Директа, статанализ ноль замечаний.
Четыре новых теста — минимум по длине периода на боевом случае, отправка на
модерацию, порядок модерация-до-включения, отсутствие повторной отправки.
Два старых теста закрепляли частный случай в два дня — им проставлен явный срок
показа, иначе они молча описывали бы неправду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:43:25 +03:00
Дмитрий 8769245ed5 feat реклама: портал сам проверяет готовность аудитории и минимальную смету до запуска
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Живой бой 30.07.2026. Сегмент дозрел за 15 часов — Яндекс опознал 1644 человека
из 1693, охват 3379. Портал нажал «Запустить» и получил отказ Директа:
   Budget for this period cannot be less than 600 rub.

Стена оказалась не одна, а две. Обе — молчаливые: портал о них не знал.

Первая — аудитория. Сегмент в Аудиториях существует и при этом НЕГОДЕН, пока
Яндекс сводит загруженные номера с людьми. Портал этого не спрашивал вообще:
ни один файл не читал can_create_dependent. Он просто шёл в Директ строить
условие ретаргетинга и получал «объект не найден» — тот же отказ, что и на
несуществующий сегмент. Судить о готовности можно ТОЛЬКО по can_create_dependent:
статус is_processed означает «обрабатывается», а не «готов».

Вторая — деньги. Директ не берёт кампанию, у которой бюджет за период ниже
шестисот рублей. Приёмочная кампания на 1693 показа давала около 146 рублей.
Проверки минимума в портале не было тоже.

В обоих случаях клиент видел сырую ошибку Яндекса на английском и не понимал,
виноват ли он и что делать дальше.

Что сделано. Обе проверки выполняются ДО первого обращения к Яндексу: в кабинете
ничего не создаётся, деньги не морозятся, кампания остаётся черновиком.
   аудитория ещё готовится → 202 и «подождите, обычно несколько часов»
   смета мала              → 422 и «нужно 6945 показов, это 833.40 рублей»

Наружу уходят ТОЛЬКО клиентские числа. Ни минимума площадки, ни нашей наценки:
по паре «минимум площадки — цена клиенту» долю Яндекса можно вычислить делением.
Минимум вынесен в настройку YANDEX_DIRECT_MIN_SPEND_RUB, Яндекс может его менять.

Тестом вперёд, в живой Яндекс из тестов не ходили. Четыре новых теста на запуск
и два на ответы портала. Реклама целиком: 342 теста зелёные.

Заодно приведён в порядок список исключений статанализа. Он был красным ЗАДОЛГО
до этой правки и не пускал ни одну правку кода: 629 замечаний до моих изменений,
638 после. Проверено в обеих папках работы — не артефакт подпапки.

Разбор 637 замечаний по составу:
   611 — статанализ не понимает устройство тестов Pest и ругается на
         обращения вида this->postJson и this->tenant. Таких записей в списке
         исключений уже было 671 — просто новые тестовые файлы туда не дописали.
    26 — придирки к типам, из них 7 в боевом коде.

Все семь в боевом коде разобраны поимённо и оказались ложной тревогой. Доказано
не рассуждением, а зелёными тестами, которые упали бы при настоящей поломке:
   shows_until, три замечания — три теста прямо проверяют закрытие кампании по
      истечении срока показа и разморозку остатка. Будь там строка вместо даты,
      первый тест упал бы, а деньги клиента остались бы заперты навсегда.
   snapshot_from и snapshot_to, два замечания — тесты ручного режима строят
      аудиторию по этим датам, на строке они бы рухнули.
   SetTenantContext строка 56 и CampaignMessageService строка 168 — лишние
      подстраховки, вреда нет.

Корень ложных срабатываний: Larastan ищет приведения типов в старом виде —
свойством casts, а у нас метод casts. Код правильный, инструмент отстал.

После обновления списка статанализ зелёный: 0 замечаний. Теперь сторож снова
ловит НОВОЕ, а не молчит красным.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:07:49 +03:00
Дмитрий 6d34d663c7 fix реклама за показы: пометка отдана на починку держит правку открытой после возврата в черновик 2026-07-28 12:28:20 +03:00
Дмитрий d29f45da11 feat реклама за показы: ручка Исправить и узкое исключение в замке правки 2026-07-28 11:43:58 +03:00
Дмитрий 958204d25d feat реклама за показы: под ярлыком Отклонено видна первая строка причины 2026-07-28 10:10:55 +03:00
Дмитрий 9cc3f5e950 fix реклама за показы: пауза не врёт про остановку, цена запуска остаётся на кампании, робот не дерётся сам с собой
Хвосты денег Д1-Д6 и робота Р-х1-Р-х6 из приёмочного листа v12.

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

Д2 суточное списание берёт кампанию под замком строки. Ключ идемпотентности зависит
от числа показов, поэтому два одновременных прогона получали разные ключи и списали
бы клиента дважды.

Д3 пауза, не дошедшая до Директа, больше не считается паузой: отказ 409, заморозка
остаётся. Раньше реклама крутилась дальше, портал показывал паузу, а деньги были уже
свободны. У возобновления поведение намеренно прежнее — иначе понадобилось бы пятое
место разморозки, а их ровно четыре. Там же убрана мина строгого сравнения рубильника.

Д4 не трогали — это вопрос владельца.

Д5 рубильник Директа держит и служебный канал робота: выдача задания и приём отчёта
ходили в живой кабинет мимо него.

Д6 проверка рубильника приведена к общему виду: YANDEX_DIRECT_ENABLED=0 давало строку,
которую строгое сравнение читало как включено.

Р-х1 настройки читаются из .env робота, а не каталога запуска.

Р-х2 файл-замок robot.lock: проход и поддержание входа больше не дерутся за профиль
браузера. Занят — уходим молча, задание остаётся в очереди. Брошенный замок
перехватывается через полчаса.

Р-х3 письмо-алярм честно говорит, залиты ли уже креативы в кабинет. Побочно вскрылось,
что тексты писем не проверялись ни одним тестом — транспорт вынесен в src/smtp.js.

Р-х4 тест-пустышка про рабочую папку заменён настоящим: запуск из чужого каталога без
явной папки. Проверено вырезанием.

Р-х6 пустое значение в окружении читается как значение по умолчанию, мусор даёт внятную
ошибку вместо тихого NaN.

Портал 293/293, робот 57/57. Денежных выходов снятия заморозки по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:11 +03:00
Дмитрий 0548fc0d8f fix реклама за показы: слепок креативов при выдаче, сверка размера перед объявлением, один баннер на слот
Хвосты портала П1-П8 из приёмочного листа v12.

П1 обрыв постановки задания больше не даёт клиенту голый 500 — 503 с человеческим
текстом и записью в журнал. Таймаут у HTTP-клиента Laravel уже был, эта половина
находки не подтвердилась.

П2 и П6 роботу отдаются только баннеры без номера креатива — тот же список
сопоставляется при отчёте. Раньше стороны расходились и опознание падало на
безупречной работе робота, плодя дубли в кабинете. Плюс постраничный обход описи
креативов: слепок обрывался на первой странице.

П3 слепок «до» снимается при выдаче задания, а не при постановке, и в той же
транзакции, что и перевод в работу. Два задания в очереди получали одинаковый
слепок, второе падало всегда. Постановка перестала зависеть от живости Яндекса.

П4 перед созданием объявлений сверяется настоящий размер каждого креатива одним
запросом. Не сошлось или креатива нет — запуск не идёт.

П5 уникальный индекс uq_ad_campaign_banner_slot, запись v9.09 в CHANGELOG схемы,
rls-reviewer GO.

П7 замок на правку расширен на сегмент Аудиторий — он создаётся раньше кампании.
Смежная находка: перезаливка картинки теперь обнуляет номер креатива.

П8 и Р-х5 файл отдаётся под настоящим расширением и типом содержимого, имя от
номера баннера; робот берёт расширение из ответа портала.

Портал 287/287, робот 43/43. Денежных выходов снятия заморозки по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 05:37:56 +03:00
Дмитрий 9234a9c2bc feat реклама показы: очередь заданий робота-грузчика, служебный канал и постановка при запуске
Портал ставит роботу задание, когда у баннеров ещё нет номеров креативов: вместо
ошибки клиент видит «готовим картинки», кампания остаётся черновиком, деньги не
морозятся. Робот берёт задания строго по одному — иначе слепки креативов до и
после перемешаются, и опознать их будет нельзя.

Канал робота закрыт своим сервис-токеном, внесён в исключения проверки CSRF и
отдаёт файл только того задания, которое сейчас в работе. Постановка задания
стоит внутри проверки рубильника Директа — при выключенном рубильнике портал в
Яндекс не ходит.

Права на новую таблицу выданы роли crm_admin_user: канал идёт через посредник
admin-db, подменяющий подключение. Нумератор выдан crm_app_user — он единственный
вставляет строки. Журнал схемы — запись v9.06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:13:02 +03:00
Дмитрий af53dfed41 fix реклама показы: возобновляемый запуск, замки на повтор и правку, гранты админке и нумераторам
Запуск кампании стал возобновляемым: номер каждой созданной в Яндексе сущности
пишется на кампанию сразу, повторный вызов переиспользует созданное и заводит
объявления только для баннеров без номера. Номер группы записывается лишь после
успешной привязки аудитории — инвариант «есть номер группы, значит аудитория на
ней висит». Обрыв связи больше не оставляет кампанию-сироту в кабинете.

Двойной заморозки денег не было и раньше — AdWalletService::freeze идемпотентен
по активному холду; закрыто тестом. Денежный код не тронут, выходов снятия
заморозки по-прежнему четыре.

Два замка: launch отказывает из любого статуса кроме draft и queued — раньше
повтор откатывал статус и launched_at; update отказывает в правке параметров
показа, как только у кампании есть yandex_campaign_id — раньше клиент мог
поменять смету, цену и адрес сайта у работающей кампании, и портал молча
расходился с Яндексом. Название менять по-прежнему можно.

Права: GRANT SELECT, UPDATE на ad_campaign_banners роли crm_admin_user — без
него админ-экран увидел бы тихий ноль. GRANT USAGE, SELECT на нумераторы семи
рекламных таблиц роли crm_app_user — bigserial без USAGE даёт отказ на бою, а на
dev невидим из-за суперпользователя. Корневая причина в db/02_grants.sql —
ALTER DEFAULT PRIVILEGES без FOR ROLE crm_migrator — вынесена отдельным вопросом
к владельцу.

Миграция 2026_07_27_100000 получила гард на существование колонок под
прод-порядок выката migrate --pretend --force плюс ручной psql.

Журнал схемы v9.04 и v9.05, формулировка проверки в v9.04 уточнена честно.
229 из 229 тестов рекламы зелёные, squawk чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 15:55:16 +03:00
Дмитрий c9406533bf feat реклама показы: ядро переведено на набор креативов вместо одного адаптивного
Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все
размеры не существует. Медийная кампания состоит из объявления на каждый размер
блока со своим креативом, поэтому строка баннера стала единицей
размер плюс файл плюс креатив плюс объявление.

Что сделано:
- у баннера появились номер креатива, номер объявления и статус модерации
- кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ
- слепок картиночных креативов аккаунта через creatives.get
- опознание своих креативов разницей слепков до и после загрузки по размеру
- запуск заводит объявление на каждый включённый баннер
- модерация считается по каждому объявлению: кампания работает, если принято
  хотя бы одно, а деньги возвращаются только когда отклонены все

Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре.

После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе
джоб модерации молча увидит ноль баннеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:33:02 +03:00
Дмитрий 00a8999207 feat(реклама показы): поле «адрес сайта» в мастере кампании + валидация
Задача 8d. Клиент вписывает в мастере адрес сайта landing_url, куда ведёт
баннер по клику. Поле на шаге «Баннеры», проверка перед переходом дальше
непустой и начинается с http, сохранение через patchCampaign, показ в
сводке шага «Проверка и отправка». Бэкенд store/update валидируют url
до 1024 символов. Так кампания не застрянет на запуске без адреса.

Реклама-модуль 192/192 зелёный на чистой базе, мастер 39/39. Правки
ортогональны деньгам.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:12:50 +03:00
Дмитрий 83c8eec63b feat(реклама показы): запуск медийной кампании через Директ + адрес сайта и id объявления
Задача 7 плана «реклама за показы». CampaignLauncher переписан под медийную
кампанию CPM: дешёвые проверки до трат денег рубильник, номер креатива, адрес
сайта, смета показов, затем аудитория не меньше 100, деньги через bcmath с
наценкой, цепочка сегмент → retargeting → CPM-кампания → CPM-группа →
медиа-таргет → баннер по готовому креативу, заморозка клиентской суммы,
статус на модерации.

Колонки ad_campaigns.landing_url и yandex_ad_id, модель, журнал схемы v9.02.
Наценка и yandex_cost_rub клиенту не видны. Реклама-модуль 190/190 зелёный.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:21:17 +03:00
Дмитрий 5a4c0e0235 feat(реклама): показы — баннеры клиента по размерам, два режима аудитории, клиентская цена и наценка
Часть 5c — баннеры: клиент грузит свой готовый файл на каждый из 15 размеров вместо автогенерации из одной картинки. Частичное утверждение флагом included, замена и удаление отдельного баннера, валидация точного размера и веса. Админ-поле цены за 1000 показов. Пример CSV для скачивания и подъём лимита загрузки.

Часть 5d — два режима сбора аудитории. Авто: скользящее окно, обновляется ежедневно, только контакты системы. Ручной: снимок сделок за период плюс свой список номеров и срок показа. Клиент сам задаёт цену за 1000 показов с дефолтом из админки. Наценка настраивается в админке, по умолчанию 40 процентов, в Директ уходит меньше, клиенту не видна нигде.

Миграции: ad_campaign_banners += included; ad_campaigns += mode/snapshot_from/snapshot_to/run_days/client_cpm_rub; ad_settings += ad_margin_percent. RLS-ревью PASS на всех миграциях. Backend 166 тестов, фронт 123 теста, сборка чистая. Маржа и yandex_cost_rub клиенту не сериализуются.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:13:20 +03:00
Дмитрий de9f0d7d8e feat(реклама): Часть 5b — мастер «за показы», отправка-заявка, скрытие маржи
Мастер CampaignWizard переделан с клик-модели на показы: окно дней → частота
+ живая смета (120 ₽/1000 показов, без маржи) → одна картинка + галерея превью
+ утверждение баннеров → проверка и «Отправить заявку». Бэкенд store/update
принимают частоту/бюджет показов (клик-поля убраны), новый submit → статус
queued («готова к запуску», реальный запуск в Директ — Часть 4). Маржа
yandex_cost_rub скрыта от клиента ($hidden, тест). CampaignList: метка queued
и показы вместо недельного бюджета. Тесты: бэкенд 99/99, фронт 102/102.

Директ: заявка на ПОЛНЫЙ доступ к API подана 26.07 (upgrade, статус «новая»);
картинка-креатив через API невозможна (creatives.add только видео) — гибрид,
баннер заливается в кабинет вручную, CreativeId вставляется в портал.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 15:45:53 +03:00
Дмитрий 314c8ce5a2 feat(реклама): API кампаний Директа (CRUD + счётчик аудитории + запуск + креатив) 2026-07-24 23:55:54 +03:00