Commit Graph

7 Commits

Author SHA1 Message Date
Дмитрий 652b708203 fix(реклама): остановка рекламы делалась дважды; добраны все бесплатные карточки приёмки
Из 29 карточек приёмки Яндекса закрыты 27. Остались две, и обе ждут не работы,
а события: живого отказа всех объявлений разом и платных комбинаций Я1-Я9.

Нашлась вторая за день тихая беда. Слушатель остановки рекламы был подписан на
событие ДВАЖДЫ: Laravel 11 сам находит слушателей в app/Listeners по типу
параметра handle(), а в провайдере он вдобавок прописан руками. Остановка не
бесплатная: по каждой кампании клиента она идёт в Яндекс за числом показов,
платит за показанное и глушит кампанию, - и всё это делалось по два раза.
Деньги уцелели по случайности: списание идемпотентно по ключу показов, снятие
заморозки тоже, а второй заход не находил кампаний, первый уже увёл их из
работы. Ручная подписка убрана.

Найдено не глазами, а вырезанием: новый сквозной сторож Я-Б3 не покраснел,
когда подписку отключили. Если бы регистрация была одна, он обязан был.

Замерено на живой кампании #13, без единого рубля сверху:
- Я-Д3: показано 21, списано 10,50 руб = 21 x 500/1000; по смете было бы 850;
- Я-М2: кампания running, заморозка 839,50 = 850 - 10,50, показы идут;
- Я-Х4: задача из очереди под боевой ролью без контекста клиента видит
  1 кампанию служебным путём и 0 обычным - тот самый молчаливый ноль,
  ради которого служебный путь и заведён.

Новые сторожа, каждый принят красным:
- Я-Б2: денег ровно на смету - запуск проходит, вторая кампания уже нет;
- Я-Б3: деньги кончились - кампания РЕАЛЬНО встала и владельцу ушло письмо;
- Я-М4: пока вердикта нет, не двигаются ни заморозка, ни списание;
- Я-М5: после «Исправить» и повторного запуска заморозка ОДНА;
- Я-Х2: десять подделок отбиты, чужой номер клиента ничего не меняет;
- Я-Д1: заморозка равна смете копейка в копейку, включая некруглую;
- Я-Д2: сплошной обход всех семи читающих путей - наценка не видна нигде
  (прежний сторож проверял один путь из семи);
- остановка рекламы подписана ровно один раз.

Прогоны: 424 теста рекламы зелёные, статанализ 0, формат чистый.

Журнал результатов приёмки дописан разделом на 05.08 - что закрыто, чем
закрыто и чего ждём.
2026-08-05 09:52:10 +03:00
Дмитрий 80987996ed fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное
Три починки, каждая сперва увидена красной.

1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету:
   чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов
   и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера
   это потолок, до которого кампания почти наверняка не дотянет. Стало
   «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги;
   за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты
   и в сводке перед отправкой.

2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное.
   Списание делает часовая задача, а она берёт только кампании со статусом
   «крутится» — остановленную пропускала навсегда. Показы последнего часа
   уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
   в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай;
   не узнал число показов — не отпускай вовсе.

3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это
   ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов.
   Вынесено наружу: денежная операция закрывается, и только потом остановка.

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

Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
2026-08-05 04:26:27 +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
Дмитрий dd894a5224 fix(реклама показы): заморозка тает при списании и снимается на всех 4 выходах кампании
Заморозка ставилась один раз при запуске на всю смету показов и не снималась
нигде — AdWalletService::release не вызывался ни одной строкой приложения.

Главное следствие было блокирующим: charge уменьшал balance_rub, но не трогал
frozen_rub, поэтому одни и те же рубли считались дважды. Свободный остаток
balance − frozen уходил в минус, а AdWalletGate::isSolvent вызывается сразу
после списания в ChargeCampaignSpendJob — клиент объявлялся неплатёжеспособным
после первого же суточного списания, и AdStopAll глушил все его кампании.
Кампания умерла бы после первого дня показов даже при полном кошельке.

Что сделано:
- charge уменьшает активный холд на списанную сумму, холд закрывается при нуле;
- release стал идемпотентным — отсутствие кошелька или холда больше не ошибка;
- выход 1 completed — CampaignImpressionCharger возвращает остаток резерва;
- выход 2 rejected — SyncCampaignModerationJob возвращает резерв целиком;
- выход 3 stopped_no_funds — PauseCampaignsOnAdStop снимает резерв;
- выход 4 paused — контроллер снимает резерв, resume морозит остаток сметы
  до обращения к Директу и отдаёт 409 с понятным текстом при нехватке денег.

Удаление кампании выходом не является — destroy разрешён только для черновика,
а черновик ещё не заморожен.

Решение по паузе согласовано с владельцем 27.07.2026: на паузе деньги свободны.

Тесты: рекламный модуль 156/156. Переписан сценарий одного существующего теста
ChargeCampaignSpendJobTest — нехватку денег теперь создаёт резерв ВТОРОЙ
кампании, так как прежняя постановка опиралась на двойной счёт и стала
недостижимой; проверяемое требование сохранено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:44:12 +03:00
Дмитрий 3b55c96a85 feat(реклама показы): суточное списание по ПОКАЗАМ через счётчик показов вместо кликов
Задача 8a. Джоб ChargeCampaignSpendJob переключён с модели за клики и наценки
30% делением на модель за показы и наценку 40% вычитанием через готовый
CampaignImpressionCharger. YandexDirectClient.getCampaignSpend заменён на
getCampaignImpressions за всё время кампании ALL_TIME, счётчик сам считает
дельту от уже списанного и идемпотентен по external_key yandex-imp.

Всё за рубильником yandex_direct.enabled ВЫКЛ, на боевых деньгах ничего не
крутится. Реклама-модуль 190/190 зелёный. Отчёт показов помечен «проверить
при go-live» ReportName без даты, ALL_TIME, формат TSV.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 00:38:18 +03:00
Дмитрий 7e0b8e53aa fix(реклама): джобы Директа перечисляют кампании через pgsql_supplier (BYPASSRLS) + SET LOCAL для списаний — иначе на проде RLS даёт 0 строк (молчаливый сбой)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 00:07:45 +03:00
Дмитрий 620cc2eeb1 feat(реклама): ChargeCampaignSpendJob — суточное списание расхода Директа (Cost×1.3) 2026-07-24 23:27:50 +03:00