Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена
02.08.2026 — каждая дыра ниже стоила живых денег.
1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта;
утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель
создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания.
Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при
включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране.
2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка
POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране.
3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08.
Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF,
пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало
в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ
и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки
определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe
из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком
маленькая: 300x200. Нужна не меньше 640x360».
4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки
файла, на обоих экранах.
Сверх плана, найдено по дороге:
5. Предохранитель накопителя: правило, сохранённое до 02.08 со ссылкой на сайт и пустым
заголовком, всё равно ушло бы в кабинет. Теперь такая пачка держится черновиком,
в журнал пишется причина no_headline.
6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой
«Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом.
Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у
webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe.
Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист.
Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено.
Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не
повторить столкновения 29.07 и 01.08.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Мой коммит уехал в main отдельно — пересадкой на актуальную вершину
и оттуда на боевой. Возвращаю main к себе, чтобы ветка не разъезжалась.
Пришло 9 коммитов, из них главное — работа соседней сессии по воронке
продаж, уже влитая в main и запушенная.
Конфликта два, оба в общих машинных файлах:
- cspell-words.txt — сторону не выбирал, объединил. Все слова обеих
сторон на месте; убрана ровно одна строка — мой же дубль «админский»,
который прошлый коммит добавил дважды. Проверено сравнением с версией
до слияния: другого отличия нет;
- docs/observer/STATUS.md — машинный файл наблюдателя со столбиком часов
процессов, взята своя версия, его всё равно переписывает хук.
Замечание на будущее: патч «минимум площадки считается по длине периода»
живёт в двух коммитах — 948807507 у меня и в ветке робота, 9df7846fd
в main. В main уехала только вторая копия, main чист. Когда моя ветка
и ветка робота пойдут в main, git встретит этот патч второй раз.
Статанализ остановил слияние, и это была НЕ ложная тревога: файл-эталон
phpstan-baseline.neon склеился из двух версий и перестал соответствовать
коду. Все 16 ошибок — в чужих файлах воронки продаж, пришедших из main
байт в байт, известного ложного класса про Pest. Эталон из main один
не подошёл: всплыли записи, нужные этой ветке. Пересобрал по проектной
процедуре в два шага, phpstan.neon вернул на место. Итог 0 ошибок,
правка эталона мелкая: +43 -31. Моих файлов среди добавленных записей
нет ни одного.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Влил fix/robot-yandex-zamok (26 коммитов: сведение с основной, стадии воронки
«Тестирование ручное» и «Выслано КП», чтение вердикта и пересдача по опросу)
в ветку заголовка объявления.
Конфликт был один — docs/observer/STATUS.md, машинный файл наблюдателя
со столбиком часов процессов. Взята своя, более свежая версия; файл всё равно
перезаписывается хуком.
Замер после слияния (своя тестовая база liderra_testing_zag, прогон в тишине):
портал 4069 тестов, 4029 прошло, 16 упало; робот 130/130.
До слияния было 4063/4018/17. Тестов больше, падений меньше — стык чистый.
Падают шесть давних классов, не связанных с этой работой: CreativeRobotEndpoint
(11 — лезет в живой Директ и получает «недействительный ключ»), CreativeJobService,
InAppNotification, PhoneRegionSmoke, ProjectExtensions, SalesOverview.
Проверено отдельно: таблица заданий роботу не ограничивает список режимов
(обычная строка), поэтому новые «read-status» и «resubmit» миграции не требуют.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Стадию меняют два разных места — результат разговора менеджера и автопереезд
по деньгам. Джоба не оставляла следа вообще, поэтому «что менялось за день»
было не из чего построить. Запись движения перенесена в событие модели: один
шов на всех, включая любой будущий третий источник.
- sales_prospect_moves — журнал всех движений (append-only, с GRANT'ами ролям);
- prev_stage на карточке — откуда приехала (кормит счётчик «69/1» у «Отказа»);
- stage += 'trash' — место под колонку «Корзина».
Сторож проверен вырезанием: без записи движения краснеют 4 теста, в том числе
тест джобы и тест API.
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков),
когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал,
«Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже
ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал —
дошла до подтверждения) и 2234490 (сайт с заголовком — дошла).
Портал спрашивает заголовок заранее, на создании черновика: обязателен только для
не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40),
поле на экране появляется по той же развилке. Робот заполняет его в кабинете.
Три ловушки, добытые живыми прогонами (описаны в коде):
- поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать;
- под описание подходит несколько элементов — нужен .first();
- серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту
давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи.
Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же
13 падающих классов до и после правки (ни одного в телеграм-части). В baseline
статанализа добавлен известный ложный класс Pest для нового файла тестов.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Основная ветка ушла вперёд на 23 коммита - приехала чужая работа по воронке
продаж, вебхуку поставщика и сверке CSV. Конфликтов было два.
1. Журнал схемы db/CHANGELOG_schema.md. В обеих ветках лежала запись v9.28
от 31.07 про разное: у нас очередь заданий роботу, у них стадии воронки
"Тестирование ручное" и "Выслано КП". Обе записи настоящие, поэтому ни одна
не выброшена: боевая v9.28 осталась на своём номере, наши две подвинуты на
v9.29 очередь заданий и v9.30 грант админ-роли. Поправлены ссылки на номер
в шапках двух миграций, перекрёстные ссылки внутри самих записей и врезка
"Перенумерация" вверху журнала - там теперь описан и этот случай.
2. docs/observer/STATUS.md - файл авто-генерируемый, взята версия основной
ветки, хук перепишет его сам.
Заголовок db/schema.sql не трогали: там своя нумерация v8.85, ни одна из
веток её не двигала.
Плюс одна настоящая находка статанализа, приехавшая с чужой работой: у метода
SupplierPortalClient::fetchDeliveredLeads в описании возвращаемого набора не
было поля tag, хотя код его уже возвращает и чужой же тест его ждёт. Дописал
одно поле в описание, логику не трогал. Список исключений статанализа пересобран
- разъехались счётчики ложного класса Pest от новых строк в чужих тестах.
Проверено на ОТДЕЛЬНОЙ тестовой базе liderra_testing_tgmerge:
телеграм на портале 244 из 244
робот 124 из 124
статанализ 0 замечаний
код-стиль чисто
полный прогон 4063 теста, 4018 прошло, 17 упало
До сведения было 4039 тестов и 20 падений - тестов стало больше, падений
меньше. Ни одно падение не касается телеграма или журнала схемы: 13 из 17 в
рекламном модуле Яндекса, из них 11 - живой отказ авторизации Яндекс Директа,
остальные счётные, от накопленных за прогон данных.
Отдельно вскрылось при проверке: общая тестовая база liderra_testing испорчена -
в ней 180 записей о применённых миграциях при 146 файлах, то есть в неё пишет
не только эта рабочая папка. Из-за этого сборка базы срывалась на первом же
шаге. Отдельная база всё вылечила. Это хвост Х2б, лечение в репозиторий не
вносил - решение владельца.
На бой ничего не выкачено, переключатель TG_ROBOT_TRANSPORT остаётся в process.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Корень оказался в Laravel: в конце каждого теста он проверяет, осталось ли
соединение в своей черновой транзакции, и если тест её закрыл сам - сбрасывает
признак "база собрана". Следующий тест пересобирает базу целиком прямо посреди
прогона, снося её под всеми остальными. В проекте полно кода со своими
транзакциями, поэтому срабатывало примерно раз на восемь прогонов.
Что сделано:
- пересборка базы теперь один раз и в самом начале прогона, а не лениво в
середине по первому файлу, который её закажет;
- признак "собрано" держится взведённым - пересборка посреди прогона стала
невозможна;
- отказ заливки схемы стал громким: раньше PDO мог вернуть отказ без ошибки,
и прогон ехал дальше по неполной схеме;
- добавлена сверка полноты сборки: сколько шагов записано против того,
сколько их лежит.
Приёмка вырезанием: новый тест-сторож зелёный с защитой и падает без неё,
прогон без защиты дольше на 12 секунд - это и есть лишняя пересборка.
Замер до и после, полный набор портала:
без правки 3175 прошло, 837 упало
с правкой, прогон 1 3971 прошло, 19 упало
с правкой, прогон 2 3971 прошло, 19 упало, списки совпали дословно
Оставшиеся 19 - давние и не от базы: 11 падают на неверном токене Яндекса,
одна на типе исключения, семь счётных от накопления данных между тестами.
Плюс план перевода чтения вердикта и пересдачи на опрос - отдельным файлом,
код по нему ещё не писался.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Конфликты только в документах: журнал «мозга» взят из main, запись v9.28
переставлена поверх боевого журнала схемы. Код не пересекался.
Сторож слоёв проверен отдельно в рабочей папке: 0 нарушений.
Сторож секретов gitleaks отработал по-настоящему.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Стадии manual_testing и kp_sent в CHECK + 4 nullable-колонки под их поля.
Откат и повторный накат проверены на своей тестовой базе измерением колонок.
Статанализ пропущен с разрешения владельца: 287 замечаний было и до правки,
все — известный ложный класс Pest, моих файлов среди них нет.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Применение итога вынуто из джоба в сервис без изменения логики — теперь его зовут
оба пути, процессный и опросный. Отчёт принимается только по заданию в работе.
Сторож принят вырезанием: без проверки статуса повторный отчёт проходит с 200.
Отдельно закрыта мина, найденная ревью защиты и отсутствовавшая в плане: роль
crm_admin_user имела на client_tg_campaigns только чтение, а канал робота пишет
туда итог — на бою приём отчёта упал бы по правам. Миграция v9.29 даёт UPDATE.
Полный набор тестов телеграма: 217 из 217 зелёные, старые в том числе.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Повод — письмо тревоги 31.07.2026 05:30 МСК: сверка №1417 отрапортовала «потеряно 7».
Разбор на бою показал, что вебхук по тем же семи пришёл через 2,5 минуты (05:32:32 ×2 +
05:33:03 ×5) и получил «уже есть». Потери не было: поставщик кладёт строку в журнал
отданного РАНЬШЕ, чем шлёт вебхук, а наша сверка (каждые 30 мин) попадала в эту щель.
Цена ошибки не только ложная тревога: добранная карточка беднее живой (в журнале нет
tag/time/phones) — у 4 из 7 не определился регион, у сделок пустые регион и город.
1. Отсрочка добора (CsvReconcileJob::GRACE_MINUTES = 15). Недостача, увиденная впервые,
уходит в карантин (Redis, карта vid => время первого обнаружения) и добирается только
следующим прогоном, если провисела дольше отсрочки. Реальная потеря доезжает максимум
через полчаса. Потеря карантина безопасна: в худшем случае добор на прогон позже.
drift и «потеряно» в письме считаются ТОЛЬКО по просроченному; «в пути» — отдельно
(новая колонка supplier_csv_reconcile_log.pending_count).
2. Журнал вебхука поставщика (новая таблица supplier_webhook_log). Прежний
logSupplierWebhook писал в webhook_log, снесённую 24.05 вместе с legacy-каналом, и
молча выходил по Schema::hasTable — журнала не было ВООБЩЕ. Из-за этого 70 отказов 404
за 10 дней никто не видел; нашлись случайно в логе nginx. Пишем статус, адрес
отправителя, запрошенный хост и отпечаток присланного ключа (первые 8 символов md5 —
отвечает «наш ключ или чужой», секретом не является). Отказы дополнительно уровнем
warning: на бою LOG_LEVEL=warning, info в журнал не попадает.
3. Текст письма-тревоги переписан: «потеря» = только то, что не дошло даже за отсрочку;
«в пути» показывается отдельной строкой и потерей не считается.
4. В catch сверки — Log::error ПЕРВЫМ действием, до обращений к БД: при испорченной
транзакции следующий запрос бросал своё исключение и настоящая причина терялась.
Проверено: тесты поставщика и вебхука 230/230 (в т.ч. 5 новых на карантин, поздний
вебхук, отчёт «в пути» и смоук шаблона письма), Larastan 0, Pint чисто. Прогон всей базы
3566/3575; 5 падений — чужие и до этих правок (замерено откатом файлов): биллинг на стыке
месяцев и маршруты телеграм-ветки.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Номера телефонов в задание не кладём — только текст, ссылка и смета.
После выката на бой перезапустить db/03_service_bypass_policies.sql.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.
Сверх самого слияния:
- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.
Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса»
не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой:
в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета.
Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу
ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при
необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов.
Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом
не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт.
Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице
«Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления
и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов,
их клиент разбирает сам.
Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены.
1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту
под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам,
500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание
в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17:
право на запись плюс нумератор. В плане про это было написано прямым текстом,
я прошёл мимо. Тесты поймать не могли: ходят суперпользователем.
2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана
как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана
убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» —
это норма, так и есть живьём.
3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже
когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы
ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит
теперь сам джоб: показываем один раз, пока сказать нечего.
4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите.
В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её
больше не берёт. Сценарий существует только при частичном отказе — тест переписан
на два объявления и теперь вырезание защиты его роняет.
Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74,
мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача 15. Программный интерфейс Яндекса причину отказа не отдаёт — на отклонённое
объявление приходит «Отклонено на модерации.» и всё. Причина висит только на экране
кабинета, и добыть её может лишь тот, у кого есть глаза.
Как теперь работает:
опрос модерации видит отказ и ставит роботу задание разведки по этому объявлению;
робот открывает список объявлений, находит ячейку своего объявления, кликает по статусу,
раскрывает строку причины, читает текст и снимает одно окно; доклад уезжает порталу формой
вместе со снимком; портал кладёт его в ленту от имени Яндекса слово в слово, клиенту
письмо и колокольчик. Робот не понял, что видит — задание сбойное, владельцу письмо,
в ленту клиенту ничего не сочиняем. В админке появилась ручка «ждёт разбора».
Четыре ловушки, пойманные по дороге и проверенные вырезанием:
1. Дедуп разведки нельзя вешать на кампанию. Отказ никуда не девается, а обход бежит
по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы
в кабинет по кругу. Ключ — номер объявления, журнал схемы v9.16.
2. Рубильник держал не выдачу задания, а построение клиента Директа. Разведке слепок
креативов не нужен, значит при выключенном рубильнике она получила бы задание,
и робот пошёл бы в живой кабинет.
3. Дедуп ленты сравнивал только с последним сообщением Яндекса. После доклада робота
обход снова клал бы «причину выясняем» поверх настоящей причины.
4. Постановка разведки шла без tenant-контекста — на бою она не сработала бы ВООБЩЕ
и молча: поиск дубля давал бы ноль, запись падала бы на политике доступа, всё это
в предупреждение журнала при зелёных тестах. Поймал rls-reviewer. Лечение — своя
транзакция с выставлением клиента, рецепт ChargeCampaignSpendJob. Сторож поставлен
на сам механизм: обычным тестом это не ловится, они ходят суперпользователем.
Заодно: разведке больше не снимается слепок креативов — лишний поход в живой Яндекс
внутри открытой транзакции.
Портал 382/382, робот 75/75, мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Закрывает находки ревью: C1-блокер + рассинхроны длины + все оранжевые. TDD, всё зелёное.
Поведение в песочнице не меняется; правки готовят ветку к боевому включению.
F1 (блокер): AdWalletService::freeze реактивирует released-hold через updateOrCreate по
4-ключу — пересдача кампании и повторная заявка на имя больше не падают на дубле ключа
23505 в боевом режиме.
F2: длины валидации выровнены под колонки БД — имя 64, ad_link 500, ord_category 200;
длинное значение даёт ошибку поля, а не замаскированный 422 от БД.
F3: авто-рассылка морозит потолок бюджета симметрично ручному запуску только в бою и
считает дневной лимит под lockForUpdate строки правила.
F4: кампания не зависает в moderating вечно — переход moderating→needs_review плюс
предохранитель уборщика по возрасту client_tg.moderation_stuck_hours=48, бронь не трогаем.
F5: единое осторожное правило возврата брони в finalize и failed — есть mts_campaign_id
значит могла уйти на модерацию → needs_review без release; нет id → failed плюс возврат брони.
F6: робот cabinet.js — денежные кнопки оплатить/списать/запустить в чёрном списке
domClickButton, finalize целит только кнопку отправки на модерацию.
F7: finalize live не врёт launched:true на шаге /payment — launched:false, stoppedAt:payment;
не дошли до /payment → падаем громко.
F8: assertCostWithinCap подключён в live-finalize — сверка фактической стоимости с потолком.
F9: GRANT SELECT служебным ролям на client_tg_campaigns миграцией 000016 — иначе
кросс-тенантные джобы Poll/Sweep видели бы 0 строк на проде; правка ложного комментария в
000011. CHANGELOG v8.93, rls-reviewer CLEAN. ДЕПЛОЙ: ПЕРЕзапустить db/03_service_bypass_policies.sql.
Приёмка: бэкенд ClientTg 196/196; робот npm test 89/89; pint/phpstan/deptrac чисто. Фронт не трогали.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Проверка прав доступа по прошлой миграции вскрыла утечку: внешний ключ на сообщение
не защищал от чужого клиента, потому что проверки целостности в PostgreSQL идут в обход
RLS, а робот ходит под ролью с кросс-тенантным доступом. Он молча увёз бы документ одного
клиента в модерацию кампании другого. Обе дыры воспроизведены вживую до правок.
v9.13 — составной ключ по кампании: документ обязан принадлежать той же кампании.
v9.15 — составные ключи по клиенту на заданиях и на ленте: клиент задания обязан совпадать
с клиентом кампании. Понадобилась потому, что моя запись про v9.13 оказалась сильнее самой
защиты — поймано повторной проверкой.
v9.14 — GRANT SELECT на ленту служебной роли, иначе робот и админский экран увидели бы
ноль строк молча.
Заодно исправлены два неверных утверждения, написанных мной же: перезапуск
03_service_bypass_policies.sql в этом выкате обязателен, а не не нужен, и шапка журнала
схемы врала только про счётчик записей, но не про номер версии.
Три записи выкатываются только вместе. Проверка в коде задачи 16 остаётся вторым рубежом.
Портал 350/350 в том числе на пересозданной с нуля базе, робот 60/60, все миграции
проверены вверх-вниз-вверх, мест снятия заморозки денег по-прежнему четыре.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Робот умел ровно одно — отвезти картинки в кабинет, и очередь молчаливо означала именно
это. Теперь у задания есть вид: upload, inspect, deliver, плюс ссылка на сообщение ленты,
документ из которого везём. Умолчание upload обязательно — задания, лежащие в очереди
на момент выката, вида не имеют.
Внешний ключ на сообщение НЕ защищает от чужого клиента: проверки целостности в PostgreSQL
идут в обход RLS, а робот ходит под crm_admin_user с кросс-тенантным доступом. Дыра пока
спящая — message_id в бою никто не пишет. Требование проверять принадлежность в коде
записано в докблоке миграции, в журнале схемы v9.12 и в приёмочных строках задачи 16.
Журнал схемы v9.12, а не v9.11 из плана: тот занят отметкой revived_at.
Портал 345/345, робот 60/60, мест снятия заморозки денег по-прежнему четыре.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Закрывает Этап 5 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
Клиент управляет именем отправителя и авторассылкой сам из кабинета; авто не тратит
без денег и сверх дневного лимита. TDD, весь бэкенд+фронт зелёный.
## 5.1 — предохранители авторассылки
Накопитель в БОЮ перед постановкой пачки проверяет: смета умещается И в свободный
остаток кошелька (balance−frozen), И в дневной лимит правила за вычетом трат за
сегодня. Не прошла — держим черновиком + Log::info('client_tg.auto_skipped', reason).
Дефолт daily_limit_rub=0 → авто выключено (само денег не потратит). В песочнице гейта
нет (деньги не трогаются, как ручной launch). Новые колонки client_tg_auto_rule:
daily_limit_rub, spent_today_rub, spent_date (счётчик за день, сброс при смене даты).
## 5.2 — клиентское API имени (SenderController)
GET /sender (статус + остаток грейса), POST /sender (завести), /sender/disable,
/sender/enable (suspended→active, идемпотентно по периоду — без двойной оплаты в месяц;
проверка средств ДО списания). Логика в TelegramSenderService (enableSender+snapshot),
контроллер тонкий. Всё скоуп тенантом.
## 5.3 — экран имени (TelegramSenderPanel.vue)
Самодостаточная панель: статус имени человеческими словами, дата оплаты, остаток грейса
при долге; suspended → «Отключено за долг» + «Включить»; нет имени → форма «Завести имя».
telegram.ts: fetchSender/createSender/disableSender/enableSender.
## 5.4 — экран авторассылки + API (AutoRuleController)
GET/PUT /api/telegram/auto-rule (вкл/выкл, объявление, порог, бюджет, дневной лимит).
Порог клиентский — новая колонка client_tg_auto_rule.batch_threshold (NULL → дефолт
конфига 367; ниже 367 API не даёт — минимум МТС). TelegramAutoRulePanel.vue: тумблер +
поля порога/бюджета/лимита. В списке кампаний авто-кампании (created_by=null) помечены
чипом «авто».
Обе панели встроены в AdvertisingTelegramView.
## Схема
Две аддитивные миграции на существующую таблицу client_tg_auto_rule (000014 daily_limit
+ счётчик за день; 000015 batch_threshold). RLS/GRANT не тронуты (табличный GRANT
покрывает новые колонки). Записи db/CHANGELOG_schema.md v8.91/v8.92; обе прогнаны через
rls-reviewer — CLEAN.
## Приёмка
TDD. Бэкенд ClientTg 177/177; phpstan/pint/deptrac чисто. Фронт полный набор
212 файлов/1538 тестов зелёные; vue-tsc+ESLint по нашим файлам чисто. Проверено вживую
в браузере (клиент demo, песочница): обе панели читают и пишут — правило сохраняется
точь-в-точь (порог/бюджет/лимит), имя заводится (pending, деньги не тронуты).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Закрывает Этап 3 «Жизненный цикл и модерация» целиком (3.1–3.6) и бэкенд-часть 4.3.
План docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
## 3.5 — робот читает статус/причину модерации по mts_campaign_id (Node)
- `parseModerationStatus` (cabinet.js) — чистый парсер статус-текста кабинета в канон
Laravel-опросчика: «Отклонена»→rejected, «Одобрена»/«Активна»→approved,
«На модерации»→moderating, «Черновик»→draft, иначе null (тогда опросчик ждёт).
Терминальные вердикты приоритетнее слова «модераци…» в строке-блобе. Юнит-тест
read-status.test.mjs (11 кейсов: регистр/nbsp/блоб/приоритет/мусор).
- `readModerationStatus` (cabinet.js) + `runReadStatus` (runner.js) + режим `read-status`
в bin/run.js: открывает список кампаний, находит ряд по id, читает статус; при отказе —
причину из слайд-модалки «Причины» (#slide-modal-root). Отдаёт JSON
{ok, moderationStatus, reason?, campaignId} — его уже разбирает RobotResult (3.4).
🔴 Читалка кабинета помечена <FLOW-CONFIRM>: DOM-обёртки ряда/модалки собраны по
живой разведке «Сессии 6» (FLOW-FINDINGS.md), но именно этим кодом live ещё не прогнаны —
подтвердить на следующем цикле модерации с разрешения владельца. Парсер от DOM не зависит.
## 3.6 — пересдача отклонённой кампании (rejected → queued + документ модератору)
- Миграция 000013: колонка `client_tg_campaigns.moderator_file_path` (varchar 500 NULL,
после media_path) + CHANGELOG схемы v8.90; rls-reviewer прогнан — чисто (nullable-колонка
данных, не tenant-скоуп, RLS не меняется). Модель — fillable.
- Endpoint `POST /api/telegram/campaigns/{id}/resubmit`: только отклонённую (иначе 422);
правки ad_text/ad_link/ord_category (валидация как store) + опц. файл модератору
(.png/.jpeg/.jpg/.pdf ≤10 МБ, сохраняется на диск local). Успех: поля обновлены,
status_reason и mts_campaign_id очищены (робот создаст новую кампанию в кабинете),
rejected→queued, dispatch RunTelegramCampaignJob afterCommit. В бою — гейт аудитории
+ freeze budget_cap_rub заново (при отказе бронь вернул опросчик 3.4; freeze идемпотентен
по ACTIVE-холду), нехватка → 409, остаётся rejected. Песочница — без брони.
- Робот: task `moderatorFile` (task.js passthrough + TelegramRobotRunner.taskPayload),
RunTelegramCampaignJob отдаёт moderator_file_path; fillAd грузит файл в поле «Комментарий
для модератора» (третий file-input, accept pdf) — помечено <FLOW-CONFIRM> (live не прогнан).
- Тесты: ResubmitTest.php (7 кейсов: rejected→queued+очистка+джоб / файл сохранён /
не-rejected→422 / live 409 / валидация / .exe→422 / чужой→404); Node task.test.js (+2).
## 4.3 — уведомление об одобрении (бэкенд был готов в 3.4, добор покрытия)
- ApproveNotifyTest.php (4 кейса): notifyTelegramCampaignApproved шлёт in-app всем активным
юзерам тенанта без pref-гейта, тело «одобрена/показы пошли»; неактивный/чужой не получают.
TDD. Приёмка (моя область, чистый прогон): весь ClientTg 150/150, робот 78/78,
ApproveNotify 4/4; phpstan 0, deptrac 0, pint чисто.
Приёмочный лист 3.6 — docs/superpowers/2026-07-28-telegram-3.6-resubmit-acceptance.md.
Осталось в Этапе 4: фронтенд 4.1/4.2/4.4/4.5 (Vue-экран + Vitest) — НЕ начато.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Этап 3 «Жизненный цикл и модерация», задача 3.1. Плюс закрыта задача 3.0
(живая разведка экрана отказа) — вердикт МТС по кампании «займ» (2231134)
пришёл: «Отклонена». Разведка read-only, деньги не тронуты.
Задача 3.1 — колонка mts_campaign_id + РАННЕЕ и надёжное сохранение:
- Миграция client_tg_campaigns.mts_campaign_id (varchar(32) NULL, после
status_reason) + запись CHANGELOG_schema v8.89 (предварит., ветка). RLS не
меняется; rls-reviewer не требуется (nullable-колонка данных).
- Робот (Node): чистый хелпер parseCampaignId(url) в cabinet.js (покрыт
тестом); runner.js захватывает id СРАЗУ после создания черновика (шаг
аудитории) и печатает маркер MTS_CAMPAIGN_ID=<id> в stderr; id теперь
идёт и в ветке ОТКАЗА (раньше терялся).
- Обёртка (PHP): TelegramRobotRunner восстанавливает id из stderr-маркера во
всех путях (таймаут/непарсабельный вывод/JSON без id); RobotResult::failed
принимает id.
- Джоб: finalize сохраняет mts_campaign_id при ЛЮБОМ исходе (успех/отказ), не
затирая ранее сохранённый id. Метод failed() не трогали — туда результат не
доходит (осознанный residual, закроют уборщик 3.1b и sweeper 3.3).
Разведка отказа (3.0) записана в bots/mts-telegram-ads/FLOW-FINDINGS.md:
причина показана текстом в слайд-модалке «Причины отклонения кампании»
(кнопка «Причины»); поля загрузки файла на экране отказа нет — документ
грузится через «Исправить» → шаг «Сообщение» → «Комментарий для модератора»;
кнопка пересдачи — «Исправить».
TDD, робот замокан, тесты на liderra_testing (номера 7999…).
Приёмка: Node 48/48 (npm test), Pest ExternalIdTest 2/2 + регрессия ClientTg
122/122, phpstan (4 боевых файла) 0, deptrac 0 нарушений, pint чисто.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Хвосты портала П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>
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог).
Задача 5.2 — обратная связь по отказу:
- миграция client_tg_campaigns.status_reason (varchar 500, nullable; RLS без изменений — колонка на существующей таблице), запись v8.88 в CHANGELOG_schema
- Campaign: status_reason в fillable
- NotificationService::notifyTelegramCampaignRejected — in-app уведомление всем активным
пользователям тенанта без pref-гейта (важное операционное сообщение доходит всегда)
- RunTelegramCampaignJob: при провале робота сохраняет причину в status_reason и шлёт уведомление
(Tenant грузится внутри tenantTx для RLS, notify — после транзакции)
- фронт: telegram.ts (+status_reason), AdvertisingTelegramView показывает причину отказа/ошибки
- тесты: RejectNotifyTest (4 Pest) + 2 Vitest на экран
Закалка робота против частых глюков кабинета МТС:
- browser.js: gotoStable() — терпеливая загрузка с перезагрузкой (кабинет виснет на пустой крутилке)
- session.js: isLoggedIn через gotoStable — больше нет ложного «вход слетел»
- cabinet.js: знакомство пропускается если его нет (только для новичков); оферта по #isOfferAccepted;
осознан шаг /payment (денежная развилка «оплатить/без оплаты» — Сессия 6)
Живая разведка кабинета зафиксирована в FLOW-FINDINGS.md (селекторы, шаг /payment, поле файла модератору).
Проверки: Pest ClientTg 93/93, Vitest экрана 7/7, node-тесты робота 20/20.
Робот в тестах замокан, тесты на liderra_testing. Реальных ПДн нет (номера фейковые 7999…).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Р1. Кампания, не добравшая смету показов, оставалась running навсегда, а заморозка
денег клиента - ACTIVE навсегда: единственным переходом в completed было
delivered >= paid_impressions, а задачи, закрывающей кампанию по истечении срока
показа, не существовало вовсе. Для медийки по списку телефонов недокрут сметы -
типовой исход, а не редкий случай.
Новая колонка ad_campaigns.shows_until хранит последний день показа - ровно тот,
что уходит в Директ параметром EndDate. Пишется вместе с yandex_campaign_id, то
есть в момент, когда дату начинает держать Яндекс; возобновляемый запуск
переиспользует уже записанную дату, чтобы портал и Директ считали срок одинаково.
У выхода 1 в CampaignImpressionCharger появилось второе условие - новых мест
вызова AdWalletService::release не добавилось, их по-прежнему ровно четыре.
Запись v9.07 в журнале схемы, rls-reviewer GO.
Р2. Отчёт робота принимался по любому заданию в любом состоянии: номер брался из
адреса как есть. Готово по чужому ещё не выданному заданию разложило бы номера
креативов чужой кампании по её баннерам - картинка одного клиента уехала бы в
объявление другого; сбой по уже закрытому заданию переписал бы правильный
результат на failed. Теперь done принимает отчёт только по заданию в статусе
taken - 409 в остальных случаях, та же проверка продублирована в сервисе.
Р3, первая половина. Проверка «в работе никого» в takeNext не блокировала строку:
две одновременные выдачи обе её проходили и уносили разные задания. Слепки
creatives.get перемешивались, а размеры блоков у всех клиентов одинаковые, поэтому
итог - тихая привязка чужого номера креатива. Гарантию даёт частичный уникальный
индекс uq_creative_job_single_taken, плюс advisory-замок первой строкой транзакции,
чтобы штатный путь спокойно отвечал роботу «работы нет».
Осталось по Р3: привязать выдачу файла к номеру задания, rls-reviewer по индексу,
запись v9.08 в журнал схемы. Ход работы - в файле PROGRESS рядом с промтом v12.
Тесты, прогнаны в одиночку: портал 256/256, робот 35/35. Все девять новых тестов
были красными до правок, каждая защита проверена вырезанием.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан.
4.1 Авто-режим (пачка):
- Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS).
- TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик
(audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold,
по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub
и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе.
Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе).
4.2 Защитный observer:
- DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob.
🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный).
- Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на
очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон
в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob.
4.3 Своё имя/бренд + помесячная оплата:
- Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U).
- TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) /
disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin.
- ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка
free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended,
кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются.
Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование
в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл).
4.4 Админ-тарифы/настройки:
- Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db.
- Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка
ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён).
Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный
(+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений.
rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema
v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в
тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Портал ставит роботу задание, когда у баннеров ещё нет номеров креативов: вместо
ошибки клиент видит «готовим картинки», кампания остаётся черновиком, деньги не
морозятся. Робот берёт задания строго по одному — иначе слепки креативов до и
после перемешаются, и опознать их будет нельзя.
Канал робота закрыт своим сервис-токеном, внесён в исключения проверки CSRF и
отдаёт файл только того задания, которое сейчас в работе. Постановка задания
стоит внутри проверки рубильника Директа — при выключенном рубильнике портал в
Яндекс не ходит.
Права на новую таблицу выданы роли crm_admin_user: канал идёт через посредник
admin-db, подменяющий подключение. Нумератор выдан crm_app_user — он единственный
вставляет строки. Журнал схемы — запись v9.06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Сессия 1 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Бэкенд-ядро — зеркало готового СМС-модуля. Робота ещё нет — он в Сессии 2.
- 8 таблиц client_tg_* с RLS tenant_isolation и GRANT crm_app_user; справочные
tariffs/settings без RLS с guarded-GRANT. rls-reviewer PASS 8 из 8.
- 7 моделей ClientTg + связи campaign->phones.
- Ступенчатая цена TelegramTariffService — ₽ за показ по объёму; тариф = потолок,
точную стоимость считает МТС.
- Сборка аудитории TelegramAudienceService — сделки за период / своя база / свой
список, нормализация телефона, стоп-лист и дедуп; отдаёт кандидатов, реальный
охват узнаёт робот после загрузки в МТС.
- Канал кошелька telegram: перенесён общий AdWallet со свежей версии портала
— модели, сервис, миграции; списание и заморозка по каналу telegram под тестами,
charge не бросает и не уходит в минус, нехватка средств ловится freeze до списания.
Проверки: 26 из 26 Pest зелёные, larastan 0, gitleaks чисто.
db/CHANGELOG_schema.md v8.86 — номер предварительный, ветка отстаёт от main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Запуск кампании стал возобновляемым: номер каждой созданной в Яндексе сущности
пишется на кампанию сразу, повторный вызов переиспользует созданное и заводит
объявления только для баннеров без номера. Номер группы записывается лишь после
успешной привязки аудитории — инвариант «есть номер группы, значит аудитория на
ней висит». Обрыв связи больше не оставляет кампанию-сироту в кабинете.
Двойной заморозки денег не было и раньше — 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>
Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все
размеры не существует. Медийная кампания состоит из объявления на каждый размер
блока со своим креативом, поэтому строка баннера стала единицей
размер плюс файл плюс креатив плюс объявление.
Что сделано:
- у баннера появились номер креатива, номер объявления и статус модерации
- кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ
- слепок картиночных креативов аккаунта через creatives.get
- опознание своих креативов разницей слепков до и после загрузки по размеру
- запуск заводит объявление на каждый включённый баннер
- модерация считается по каждому объявлению: кампания работает, если принято
хотя бы одно, а деньги возвращаются только когда отклонены все
Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре.
После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе
джоб модерации молча увидит ноль баннеров.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Задача 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>
- config yandex_direct.spend_limit_guard_multiplier — бэкстоп от перерасхода, не клиентская цена
- миграция ad_campaigns.yandex_creative_id nullable + точечный GRANT UPDATE админ-роли с гардом
- модель AdCampaign: yandex_creative_id в fillable и casts integer
- тесты: config-набор и миграция зелёные, реклама-набор не сломан
- CHANGELOG схемы v9.01; план Части 4 и findings контракта медийного API Директа
Часть B «мотор» ещё впереди: медийные методы клиента, переписанный запускатор, контроллер.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Часть 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>
Часть 3b-1 из 6. Из одной картинки клиента генерируется и сохраняется полный
набор баннеров кампании (15 размеров Яндекса).
- Таблица ad_campaign_banners (RLS tenant_isolation, GRANT crm_app_user SELECT/INSERT/DELETE,
клиентская — srv_bypass не нужен). Модель AdCampaignBanner.
- CampaignBannerService::generate — прогон исходной картинки по BannerSizes через
BannerGenerator, файлы на приватный диск local, строки в БД; перегенерация заменяет набор.
- CHANGELOG_schema v8.97 + предупреждение для Части 4 (джоб под crm_supplier_worker
потребует GRANT + srv_bypass re-run, иначе тихий ноль).
Тесты: 9/9 зелёные (Storage::fake). rls-reviewer: PASS.
Spec: docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md §4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Перевод рекламного модуля с «за клики» на «за показы» (медийная кампания),
Часть 1 из 6 — денежное ядро и модель. На бой не выкачивается.
- AdImpressionPricing: чистый калькулятор (показы=аудитория×частота,
клиентская сумма по 120₽/1000 с округлением вверх до копейки, маржа); bcmath scale 2.
- ad_settings.client_cpm_rub (default 120.00) — плоская клиентская цена, наценка клиенту не видна.
- ad_campaigns: поля модели «за показы» (frequency, *_impressions, budget_rub,
yandex_cost_rub, charged_client_rub), weekly_budget_rub → nullable (клик-наследие).
- AdCampaign: fillable/casts + default delivered_impressions=0.
- CHANGELOG_schema v8.96 + требование скрытия маржи (yandex_cost_rub) в клиентской выдаче.
Тесты: 12/12 рекламных зелёные (вкл. регресс кошелька). rls-reviewer: PASS.
Spec: docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись:
- B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder
(open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming)
и боевых СМС, guard по источнику. rls-reviewer OK 8/8.
- B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала.
- B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов),
тип WarmingBadgeState в sales.ts.
- B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане;
осиротевший WarmingChannelIcons удалён.
Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет).
cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca).
Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный.
B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>