Commit Graph

1667 Commits

Author SHA1 Message Date
Дмитрий a1567ff545 fix реклама Яндекса: сегмент Аудиторий указывается с приставкой 20 — без неё Директ отвечает «объект не найден»
Настоящая причина, по которой портал не мог завести кампанию. Прежний диагноз «сегменту
нужно не меньше 1000 опознанных людей» оказался ВЫДУМКОЙ: он был построен на статьях,
а не на замере, и увёл в ожидание, которое ничего не решало.

Что происходит. Сегмент Аудиторий указывается в условии ретаргетинга не своим номером,
а номером с приставкой 20: сегмент 58034825 уходит как ExternalId 2058034825. Без приставки
Директ ищет цель Метрики с таким номером и честно отвечает 8800 «Object not found» — тем же
отказом, что и на несуществующий сегмент. Поэтому поломка выглядела как «аудитория не готова».

Замер на боевом кабинете 30.07 на одном и том же ГОТОВОМ сегменте:
   ExternalId 58034825   -> отказ 8800 «Object not found»
   ExternalId 2058034825 -> условие создано, № 41678818
После починки то же самое кодом портала: условие № 41678854 создано и убрано за собой.

Приставку показал сам Яндекс: у уже существующего в кабинете условия на этот сегмент
retargetinglists.get возвращает ровно "ExternalId": 2058034825. Документация про приставки
говорит глухо и только «для интересов» — живой ответ кабинета оказался надёжнее документации.

Попутно снят ложный «дефект продукта». Порога в 1000 человек НЕ существует: сегмент годится
для Директа при 134 опознанных людях из 151 номера. Минимум портала менять не нужно.

Ещё один замер, важный на будущее: статус is_processed у сегмента — это НЕ «готов»,
у готового статус processed, заполнены matched_quantity и cookies_matched_quantity,
и стоит can_create_dependent. Судить о готовности только по can_create_dependent.

Тестов на приставку было ДВА, и оба закрепляли поломку: проверяли, что уходит номер БЕЗ
приставки. Оба были зелёные всё время. Тест, написанный по нашему представлению о правде,
а не по ответу живой системы, охраняет баг и делает его невидимым. Теперь оба проверяют
приставку и падают без неё; реклама портала 43 из 43.

Вместе с починкой ложатся три промта смен: 29.07 сведение телеграма и прокси, 29.07 робот
креативов в работу и 30.07 состояние после живой приёмки. В последнем ложный диагноз про
порог в 1000 человек помечен как неверный прямо в шапке — чтобы следующая смена не пошла
по нему второй раз.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:57:38 +03:00
Дмитрий f2bfe3f1da fix реклама Яндекса: пять поломок, которые вскрыл первый живой запуск связки портал-робот-кабинет
Ни одну нельзя было увидеть без настоящего прогона: часть закрыта песочницей Яндекса,
часть — швами между кусками, которые по отдельности покрыты тестами.

1. Слепок креативов уходил с SelectionCriteria пустым МАССИВОМ. Живой Яндекс отвечает
   отказом 8000 «SelectionCriteria cannot contain an array», портал не может снять слепок,
   задание роботу не выдаётся, клиент видит вечное «готовим картинки». В JSON нужен пустой
   ОБЪЕКТ. Песочница до этой проверки не доходила — отвергала запрос раньше, на входе.

2. Отказы Яндекса ПО ПОЗИЦИИ внутри ответа глотались молча. Запрос успешен, а нужного Id
   в позиции нет — вместо него Errors с человеческим объяснением. Портал падал ошибкой PHP
   «Undefined array key Id», ни клиенту, ни в журнал не попадало ни слова из ответа Яндекса.
   Теперь слова Яндекса выходят наружу — именно это и позволило найти пункт 3.

3. В условии ретаргетинга не передавался MembershipLifeSpan — срок хранения человека
   в сегменте. Живой Яндекс отказывает «Required field: Not specified time for goal or
   segment», и следом «Object not found»: довод негоден целиком, запуск встаёт. Берём
   audience_days кампании, границы Яндекса 1..540.

4. Робот не отдавал набор Яндексу: заливал файлы, нажимал «Создать» и уходил. Оказалось,
   «набор» в кабинете и «креатив» в creatives.get — разные вещи: пока набор не отмечен
   галочкой и не нажато «Добавить выбранные», Яндекс креативы не регистрирует. Замер:
   14 залитых картинок были невидимы для creatives.get, после добавления в ЧЕРНОВИК формы
   счётчик прыгнул 18 -> 32 в ту же секунду. Объявление при этом не сохраняется,
   «Сохранить изменения» робот по-прежнему не трогает никогда.

5. Гонка при заливке. Три живых прогона подряд из 15 картинок доносили 14, и каждый раз
   пропадала ДРУГАЯ: 480x320, потом 300x600, потом 336x280. Замер объяснил: кнопка
   «Создать» разблокируется на 4-й секунде, когда принято 11 файлов из 15, остальные
   дозагружаются к 7-й. Робот жал сразу по разблокировке. Ждём теперь по строкам принятых
   файлов и по исчезновению слова «Загружается».

   Первая попытка этой починки НЕ РАБОТАЛА и выглядела рабочей: сторож считал размеры
   в окне, не заметив постоянного фильтра из тех же 15 размеров вверху. Поймано по тому,
   что прогон занял ровно столько же секунд, сколько до починки. Отсюда тире в признаке
   приёма — фильтр его не содержит.

Каждая починка закрыта тестом, который сначала падал на своей поломке. Прежние тесты
проверяли разбор ОТВЕТОВ и путь ДО «Создать» — форму запросов и то, что после, не смотрел
никто. Робот 84 из 84, реклама портала 42 из 42.

Проверено живьём на боевом: задание роботу отработало со статусом done, опознано
15 креативов из 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 07:14:33 +03:00
Дмитрий 77f61fb1d1 merge: сведение ветки «Реклама Телеграм» с боевым main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Слияние 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>
2026-07-29 16:41:38 +03:00
Дмитрий dcf9706703 Merge branch 'main' into feat/reklama-yandex-pokazy
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
2026-07-29 08:06:59 +03:00
Дмитрий 0204411e98 fix реклама за показы: из очереди кошелёк молча не возвращал клиенту заморозку
Тот же класс, что нашли во втором проверяющем на СМС: чтение денег идёт вне куска
с контекстом клиента. Проверил свой модуль целиком по этому признаку.

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

Чем кончалось бы на бою: Яндекс отклонил весь набор объявлений - кампания помечена
отклонённой - возврат заморозки видит "кошелька нет", честно выходит - деньги клиента
остаются замороженными навсегда. Ни ошибки, ни строки в журнале, свободный остаток
занижен, новую кампанию запустить не на что.

Живой замер на опытной базе, откатанный сразу:
суперюзером, как ходят тесты - 1
боевой ролью без контекста, как читает джоб - 0
боевой ролью с контекстом - 1

Тесты ходят суперюзером, который защиту обходит, и увидеть это физически не могут.

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

Сторож гоняет деньги ПОД БОЕВОЙ РОЛЬЮ и без контекста. Написан до починки, падал
двумя разными способами: возврат молча ничего не делал, заморозка бросала "кошелька
нет". Опасен именно молчаливый.

Обход остальных фоновых путей: перечисление кампаний, сторож зависших заданий,
сборка аудитории, постановка задания разведки, запись в ленту - у всех защита на
месте. Список получателей уведомления читается из таблицы, у которой политика мягкая:
без контекста она открыта, ноль строк не даёт.

Реклама 395 из 395 при 1251 проверке, было 393. Админка и кошелёк 25 из 25.
На боевой не выкатывалось, никуда не отправлялось.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:56:18 +03:00
Дмитрий 7d8c32bb53 fix реклама за показы: пауза и следом возобновление кампании падали на дубле брони денег
Найдено при разборе шва с веткой телеграм-рекламы: обе ветки правили один денежный
файл с разных сторон, и сравнение вскрыло поломку у нас.

Снятие заморозки не удаляет строку брони, а метит её снятой. На броне висит запрет
двух одинаковых записей по четвёрке тенант-канал-тип-источник. Повторная заморозка
той же кампании заводила строку заново и падала на дубле ключа.

По-человечески: клиент ставил кампанию на паузу и больше не мог её включить. Та же
дорога на новом пути отказ модерации - Исправить - отправить заново.

Почему 391 зелёный тест этого не видел. Есть два теста, и каждый честен по
отдельности: первый морозит и снимает, второй берёт кампанию, которую никогда
не морозили, и морозит. Последовательность снять и заморозить снова не проверял
никто - шов между двумя половинками остался голым.

Проверено прогоном, не рассуждением: база ответила дублирующееся значение ключа
нарушает ограничение уникальности ad_wallet_holds по ключу yandex campaign 1.

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

Реклама 393 из 393 при 1249 проверках, было 391. Админка и кошелёк 23 из 23.
На боевой не выкатывалось, никуда не отправлялось, рубильник Директа выключен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 06:51:25 +03:00
Дмитрий 6f469fb13d feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления
Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере)
оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске,
возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund).
Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены).
Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён
(сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом.

Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы,
а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС:
- «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет):
  ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting,
  фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee.
  Таблицы client_tg_senders/_settings дропнуты.
- мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны
  сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены).
- client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»).
- лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс».

Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media
(картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле
загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile
→ cabinet.js fillAdMedia) — не хватало только приёма файла от клиента.

Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп
СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному):
Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view
19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот
bots/mts-telegram-ads НЕ трогали.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 06:26:07 +03:00
Дмитрий 3fdcd88ad3 feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 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>
2026-07-28 20:10:18 +03:00
Дмитрий 0ba7890bf9 feat реклама за показы: робот-разведчик приносит клиенту причину отказа с экрана кабинета
Задача 15. Программный интерфейс Яндекса причину отказа не отдаёт — на отклонённое
объявление приходит «Отклонено на модерации.» и всё. Причина висит только на экране
кабинета, и добыть её может лишь тот, у кого есть глаза.

Как теперь работает:
опрос модерации видит отказ и ставит роботу задание разведки по этому объявлению;
робот открывает список объявлений, находит ячейку своего объявления, кликает по статусу,
раскрывает строку причины, читает текст и снимает одно окно; доклад уезжает порталу формой
вместе со снимком; портал кладёт его в ленту от имени Яндекса слово в слово, клиенту
письмо и колокольчик. Робот не понял, что видит — задание сбойное, владельцу письмо,
в ленту клиенту ничего не сочиняем. В админке появилась ручка «ждёт разбора».

Четыре ловушки, пойманные по дороге и проверенные вырезанием:

1. Дедуп разведки нельзя вешать на кампанию. Отказ никуда не девается, а обход бежит
   по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы
   в кабинет по кругу. Ключ — номер объявления, журнал схемы v9.16.
2. Рубильник держал не выдачу задания, а построение клиента Директа. Разведке слепок
   креативов не нужен, значит при выключенном рубильнике она получила бы задание,
   и робот пошёл бы в живой кабинет.
3. Дедуп ленты сравнивал только с последним сообщением Яндекса. После доклада робота
   обход снова клал бы «причину выясняем» поверх настоящей причины.
4. Постановка разведки шла без tenant-контекста — на бою она не сработала бы ВООБЩЕ
   и молча: поиск дубля давал бы ноль, запись падала бы на политике доступа, всё это
   в предупреждение журнала при зелёных тестах. Поймал rls-reviewer. Лечение — своя
   транзакция с выставлением клиента, рецепт ChargeCampaignSpendJob. Сторож поставлен
   на сам механизм: обычным тестом это не ловится, они ходят суперпользователем.

Заодно: разведке больше не снимается слепок креативов — лишний поход в живой Яндекс
внутри открытой транзакции.

Портал 382/382, робот 75/75, мест снятия заморозки денег по-прежнему четыре.
На боевой не выкатывалось, рубильник Директа выключен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:28:31 +03:00
Дмитрий 7a2b052f0d feat(телеграм-реклама): связка Laravel → робот mode:resubmit — пересдача чинит ту же кампанию
Хвост «связка» из STATE. Раньше пересдача отклонённой кампании создавала в кабинете
МТС НОВУЮ кампанию (RunTelegramCampaignJob, очищала mts_campaign_id). Теперь портал
поручает роботу ЧИНИТЬ ту же кампанию: робот заходит в неё через «Исправить» по
mts_campaign_id, вносит правки и переотправляет на модерацию «без оплаты» (0 ₽).
Робот-режим mode:'resubmit' уже проверен живьём (коммит d50a93e5). Выбор поведения
(«чинить ту же», не «создавать новую») подтверждён владельцем.

Что сделано:
- RobotResult: поле resubmitted (bool) + разбор из JSON робота.
- TelegramRobotRunner::taskPayload: пробрасывает submitMode (draft|live для resubmit).
- ResubmitTelegramCampaignJob (новый): RLS через tenantTx (SET LOCAL current_tenant_id),
  идемпотентность по queued, денежно-статусная логика F5 как в RunTelegramCampaignJob.
  Успех: бой (resubmitted) → moderating, песочница → draft_ready. Отказ с mts_campaign_id
  → needs_review без возврата брони.
- Контроллер resubmit: mts_campaign_id СОХРАНЯЕТСЯ (не очищаем), guard на пустой id → 422,
  dispatch ResubmitTelegramCampaignJob.

Код-ревью (субагент) поймало Important-баг, исправлено:
- I-1: failed() был скопирован из эталона, где «queued ⇒ нет mts_campaign_id». У пересдачи
  queued ВСЕГДА с id → при перманентном сбое в queued срабатывала ветка hasMtsId →
  queued→needs_review (перехода НЕТ в TRANSITIONS) → кампания зависала в queued с
  замороженной бронью, уборщик её не метёт. Фикс: queued → failed + release (робот кабинет
  не трогал — Фаза A не закоммитила running); running — прежняя осторожная логика.
- M-1: успех в бою с resubmitted=false (аномалия контракта) давал терминальный draft_ready
  с зависшей бронью → needs_review (бронь под ручную сверку).

Приёмка: ResubmitJobTest + ResubmitTest вместе 15/15 (51 проверка); pint/phpstan(0)/
deptrac(0) чисто; схема БД не менялась. Детали и приёмочный лист:
docs/superpowers/2026-07-28-resubmit-svyazka-ACCEPT.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:16:48 +03:00
Дмитрий 865bd211b1 fix реклама за показы: клиент узнаёт про отказ понятными словами, а не отпиской Яндекса
Яндекс на отказ отдаёт машине «Отклонено на модерации.» и больше ничего. Из этого
выходило три беды сразу: в переписке клиент видел одну бесполезную фразу, в списке
кампаний под ярлыком «Отклонено» не было вообще ничего — подпись берётся как первая
строка, а первым знаком у настоящего ответа идёт перенос, — и то же самое уезжало
клиенту письмом.

Новый ModerationReason — единственное место, где ответ модерации превращается в текст
для клиента. Отписку заменяем честным «Яндекс отклонил рекламу, но причину не назвал.
Выясняем — как только узнаем, напишем здесь». Первая строка нарочно короткая: она идёт
подписью под ярлыком. Настоящую причину принесёт разведка, задача 15.

Ловушка, пойманная до написания: подмену нельзя звать на все объявления подряд —
у принятого пустое пояснение это норма, и подмена приписала бы принятой рекламе отказ.
Зовём только при отказе, на ловушку стоит отдельный тест-сторож.

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

Проверено вырезанием: без подмены два теста краснеют именно на возврате отписки,
а тест про обрезку переносов остаётся зелёным.

Портал 361 из 361, экраны рекламы 37 из 37, робот 60 из 60, мест разморозки денег
по-прежнему четыре.
2026-07-28 18:16:19 +03:00
Дмитрий cce04aed86 feat реклама за показы: постановка доставки берёт документ только связью от кампании
Второй рубеж защиты документа. Оставлять его на задачу 16 было бы хвостом: сама проверка
от экранов кабинета не зависит. enqueueDelivery берёт сообщение связью от кампании,
сырой номер в выборку не попадает нигде. Заодно отказывается ставить задание без вложения
и не плодит второе, если клиент нажал дважды.

Поймана ловушка, заложенная прошлой задачей: постановка обычной заливки искала любое
незавершённое задание кампании и с появлением доставки вернула бы её. Запуск решил бы,
что креативы уже в очереди, и робот не повёз бы картинки вовсе, молча. Отбор по виду
добавлен, тест есть. Нашлось чтением соседнего метода, не тестом и не проверкой.

Оба рубежа доказаны вырезанием по отдельности: без проверки в коде чужой документ ловят
ключи базы, но уже ошибкой записи вместо понятного отказа.

Портал 356/356, робот 60/60, мест снятия заморозки денег по-прежнему четыре.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:55:29 +03:00
Дмитрий 68fe632cca fix(телеграм-реклама): правки по сводному код-ревью ветки — деньги, статус-машина, робот, RLS
Закрывает находки ревью: 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>
2026-07-28 15:07:53 +03:00
Дмитрий 03ef89e06e fix реклама за показы: чужой документ теперь отказывает сама база, а не дисциплина в коде
Проверка прав доступа по прошлой миграции вскрыла утечку: внешний ключ на сообщение
не защищал от чужого клиента, потому что проверки целостности в 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>
2026-07-28 15:07:20 +03:00
Дмитрий a35b28cb15 feat реклама за показы: вид задания у робота — отвезти картинки, посмотреть, отвезти документ
Робот умел ровно одно — отвезти картинки в кабинет, и очередь молчаливо означала именно
это. Теперь у задания есть вид: 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>
2026-07-28 14:37:43 +03:00
Дмитрий aa4b482d45 feat(телеграм-реклама): Этап 5 — «Своё имя» + «Авторассылка» из кабинета + предохранители трат
Закрывает Этап 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>
2026-07-28 14:02:56 +03:00
Дмитрий 9b9055753b feat(телеграм-реклама): Этап 4 — клиентский опыт + причёсывание экрана под домашний стиль
Закрывает фронтенд Этапа 4 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md
(бэкенд 4.3 — уведомление об одобрении — сделан в Этапе 3). Чистый Vue+Vitest, TDD.

## 4.1 — смета и охват ДО запуска
Разделены create/launch в UI: кнопка «Рассчитать» создаёт черновик (createTelegram) и
показывает прогноз стоимости + охват; «Запустить» доступна только после расчёта. Любая
правка формы сбрасывает расчёт (watch), чтобы не запустить по устаревшим данным.

## 4.2 — автообновление статуса
Пока есть «живые» кампании (queued/running/moderating) — экран сам зовёт fetchTelegram по
интервалу (15с), setInterval со снятием в onUnmounted; вне живых статусов не опрашивает.

## 4.4 — экран отказа: пересдача + пустая причина
У rejected-кампании кнопка «Исправить и пересдать» → инлайн-форма правки (текст/ссылка/ОРД +
опц. документ модератору) → новый resubmitTelegram в telegram.ts (multipart POST .../resubmit).
При пустой status_reason — единая заглушка REASON_PLACEHOLDER (действенная, без «круга»).
В интерфейс TelegramCampaign добавлено moderator_file_path.

## 4.5 — подсказки
У moderating — «проверка ~4 часа»; пока песочница — метка «песочница» на каждой кампании.

## Причёсывание под домашний стиль кабинета (осмотр вживую 28.07)
Экран приведён к виду соседних клиентских экранов (эталон DashboardView): обёртка
v-container fluid pa-6 + scoped max-width:1100px (были прижаты к краям во всю ширину);
крупный заголовок + строка-описание; поля density=compact variant=outlined (была «рыхлая»
форма); контент собран в карточки «Новая кампания» / «Мои кампании» (была «полосатая» зона).
Палитра Forest не тронута. STATUS_LABELS дополнен moderating/needs_review/cancelled.
Починена мелочь-логика: «Рассчитать»/«Запустить» больше не активны с пустым списком номеров
в режиме «Свой список».

Миграция не нужна (moderator_file_path добавлена в Этапе 3; новые ярлыки статусов — без БД).

TDD. Приёмка (моя область): фронт-тесты Телеграма 22/22; полный фронт-набор 210 файлов /
1529 зелёные; vue-tsc + ESLint по изменённым файлам чисто. Проверено вживую в браузере
(клиент demo): двухшаговые кнопки, скрытие/показ полей по аудитории, гейт по номерам.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:57:57 +03:00
Дмитрий 653b095a2a docs реклама за показы: заход 3 закрыт — кусок оживления готов, ход работ и снимок состояния 2026-07-28 12:36:34 +03:00
Дмитрий 6d34d663c7 fix реклама за показы: пометка отдана на починку держит правку открытой после возврата в черновик 2026-07-28 12:28:20 +03:00
Дмитрий 89e6a45960 feat реклама за показы: кнопка Исправить оживляет кампанию перед мастером 2026-07-28 11:55:26 +03:00
Дмитрий d29f45da11 feat реклама за показы: ручка Исправить и узкое исключение в замке правки 2026-07-28 11:43:58 +03:00
Дмитрий 18135b47b4 feat(телеграм-реклама): Этап 3.5/3.6 + 4.3 — чтение вердикта, пересдача, уведомление об одобрении
Закрывает Этап 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>
2026-07-28 11:29:23 +03:00
Дмитрий 729a2636df feat реклама за показы: сервис оживления отклонённой кампании 2026-07-28 11:18:40 +03:00
Дмитрий abb3941ec0 feat реклама за показы: удаление отклонённых объявлений в кабинете Яндекса 2026-07-28 11:10:54 +03:00
Дмитрий a7e1dbeaf7 feat(телеграм-реклама): Этап 3.4 — опросчик вердикта модерации МТС
Этап 3 «Жизненный цикл и модерация», задача 3.4. Живая отправка кампании
ставит статус `moderating` (задача 3.2), но одобрение/отказ приходит от МТС
позже и без API. Опросчик раз в цикл робот-читалкой заходит в кабинет по
`mts_campaign_id` и применяет вердикт — КОНСЕРВАТИВНО с деньгами.

- PollTelegramModerationJob: раз в 15 минут перечисляет `moderating`-кампании
  с `mts_campaign_id` (без id на модерацию уйти не могли — пропускаем) и читает
  вердикт роботом РОВНО ОДИН РАЗ на кампанию (каждый вызов = заход в браузер):
    · `rejected` («Отклонена») → статус `rejected` + причина из кабинета; возврат
      брони кошелька (в бою, ключ совпадает с freeze); уведомление «отклонена»;
    · `approved` («Одобрена») → `launched`; уведомление «одобрена». Деньги НЕ
      трогаем — бронь под потолок держится, фактическую стоимость спишет билинг
      по факту показов (2.4 → позже);
    · `moderating` / робот не прочитал вердикт (null) → НЕ трогаем, ждём цикла.
  Кросс-тенант через pgsql_supplier (BYPASSRLS), правки под SET LOCAL
  app.current_tenant_id на дефолтном соединении; возврат брони и уведомление —
  ПОСЛЕ транзакции статуса, каждый в своём try/catch. Повторная проверка
  status===moderating под lockForUpdate (гонка). Зеркалит SweepStuckTelegramCampaignsJob.
- RobotResult: поле `moderationStatus` (approved|rejected|moderating|null) + разбор
  в fromRobotJson.
- TelegramRobotRunner: метод `readModeration($mtsCampaignId)` (режим read-status,
  Node-сторона — задача 3.5); `campaignId` проброшен в task-payload.
- NotificationService: `notifyTelegramCampaignApproved` (зеркало Rejected, in-app
  без pref-гейта) + событие EVENT_TG_CAMPAIGN_APPROVED.
- console.php: расписание опросчика everyFifteenMinutes с heartbeat-трекингом.

Селекторы экрана отказа — из живой разведки Part B (bots/mts-telegram-ads/
FLOW-FINDINGS.md, «Разведка Сессии 6»); повторного захода в кабинет не потребовалось.
Миграция не нужна — новых колонок/статусов нет (moderating/rejected уже были).

TDD. Приёмка (моя область, чистый прогон): PollModerationTest 6/6 (отклонена/
одобрена/ещё-на-модерации/не-прочиталось/без-id + деньги: при отказе бронь
возвращена, при одобрении не тронута) + весь набор ClientTg 143/143.
phpstan 0, deptrac 0, pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:18:21 +03:00
Дмитрий fb63994a66 test реклама за показы: в ленте не может быть наценки и пути файла на сервере 2026-07-28 10:17:38 +03:00
Дмитрий 958204d25d feat реклама за показы: под ярлыком Отклонено видна первая строка причины 2026-07-28 10:10:55 +03:00
Дмитрий 31709d01f5 feat реклама за показы: экран переписки в карточке кампании вместо мёртвого списка объявлений 2026-07-28 10:01:24 +03:00
Дмитрий f0a7de7d5d fix(портал продаж): ИНН при заведении кандидата больше не обязателен
Обязательно только название фирмы — вывеска. ИНН по желанию: заполнили
проверяем контрольную сумму и ловим дубль, оставили пустым — карточка
заводится без ИНН. Пустая строка приравнена к «не знаю» и уходит как null.

Проверка на дубль по ИНН теперь срабатывает только когда ИНН указан:
раньше вторая карточка без ИНН у того же менеджера ложно ловилась бы как
«эта фирма уже есть в вашей воронке». Схему БД менять не потребовалось —
колонка inn была nullable, уникальный индекс частичный.

Тесты: сервер 58/58, экран 9/9. Спека §16 обновлена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:51:52 +03:00
Дмитрий 2736724511 feat реклама за показы: ответ клиента с документом и отдача файла только своему тенанту 2026-07-28 09:45:54 +03:00
Дмитрий a736ff087d feat(телеграм-реклама): Этап 3.3 — уборщик зависших кампаний + таймаут
Этап 3 «Жизненный цикл и модерация», задача 3.3. Робот в браузере может
оборваться на полпути (воркер убит, сеть отвалилась) — кампания навсегда
виснет в `running`, а замороженные деньги залипают. Уборщик добивает статус,
но КОНСЕРВАТИВНО с деньгами.

- SweepStuckTelegramCampaignsJob: раз в 10 минут ищет `running` старше 15 минут
  (штатный робот ≤ 420с). Развилка по факту создания черновика в кабинете:
    · нет mts_campaign_id (черновик не создан) → кампания заведомо не ушла →
      `failed` + возврат брони кошелька;
    · есть mts_campaign_id (черновик создан) → могла уйти на модерацию МТС →
      деньги вслепую НЕ возвращаем, `needs_review` до ручной сверки (задача 3.4).
  Кросс-тенант через pgsql_supplier (BYPASSRLS), правки под SET LOCAL
  app.current_tenant_id на дефолтном соединении; release гейтится на !sandbox,
  ключ совпадает с freeze. Повторная проверка status===running под lockForUpdate
  (гонка с finalize). Зеркалит связку ChargeTgNameFeeJob + RunTelegramCampaignJob.
- Campaign.php: константа STATUS_NEEDS_REVIEW; переходы running→needs_review
  (терминальный) и queued→failed (для failed() джоба при раннем сбое). Миграция
  не нужна — на колонке status нет CHECK, машина статусов в модели.
- RunTelegramCampaignJob: public int $timeout = 420 (робот ~300с + навигации);
  failed() теперь умеет queued→failed (переход добавлен в модель).
- console.php: расписание уборщика everyTenMinutes с heartbeat-трекингом.

TDD. Приёмка (моя область, чистый прогон): SweepStuckTest 7/7 (в т.ч. деньги —
с черновиком не тронуты, без черновика возвращены) + StatusMachine/Moderating/
ExternalId/RunCampaignJob/LaunchIdempotency/RobotResult/RobotRunner 49/49.
phpstan 0, deptrac 0, pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 09:44:09 +03:00
Дмитрий e9bec6d11e fix(портал продаж): ИНН при заведении кандидата больше не обязателен
Обязательно только название фирмы — вывеска. ИНН по желанию: заполнили
проверяем контрольную сумму и ловим дубль, оставили пустым — карточка
заводится без ИНН. Пустая строка приравнена к «не знаю» и уходит как null.

Проверка на дубль по ИНН теперь срабатывает только когда ИНН указан:
раньше вторая карточка без ИНН у того же менеджера ложно ловилась бы как
«эта фирма уже есть в вашей воронке». Схему БД менять не потребовалось —
колонка inn была nullable, уникальный индекс частичный.

Тесты: сервер 58/58, экран 9/9. Спека §16 обновлена.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:39:19 +03:00
Дмитрий 5e5a9c7f7a feat реклама за показы: ручка списка сообщений кампании — только своя лента 2026-07-28 09:38:04 +03:00
Дмитрий 86c560e0c4 feat(телеграм-реклама): Этап 3.2 — статус «на модерации» + переходы
Этап 3 «Жизненный цикл и модерация», задача 3.2. Живая отправка робота в МТС
означает «ушло на модерацию», а не «запущено»: кампания получает статус
`moderating`, а `launched` придёт позже, когда опросчик вердикта (задача 3.4)
увидит одобрение.

- Campaign.php: константа STATUS_MODERATING + переходы running→moderating,
  moderating→launched|rejected, rejected→queued (пересдача, задача 3.6).
  running→launched оставлен ради обратной совместимости. Миграция НЕ нужна:
  на колонке status нет CHECK-ограничения (машина статусов — в модели),
  `moderating` влезает в varchar(16).
- RunTelegramCampaignJob.finalize(): живой успех (launched=true) → moderating
  вместо launched; песочница (черновик) по-прежнему → draft_ready.

Тест-инфра (побочно, но необходимо для проверки): tests/TestCase.php получил
`protected $dropTypes = true`. RefreshDatabase's migrate:fresh дропал таблицы,
но НЕ типы Postgres; при заблокированном дропе таблицы её composite row-type
переживал db:wipe, и перезагрузка db/schema.sql падала на дубле типа
(«legal_entities … уже существует»), оставляя ЧАСТИЧНУЮ схему — давний
интермиттентный флак «migrate:fresh иногда прерывается» (site_events/
client_tg_tariffs случайно отсутствовали). Дроп типов на каждом refresh резко
снизил флак (было 15–46/130 → стало 93–126/130). Только для APP_ENV=testing,
на прод не влияет. Остаточный редкий обрыв — отдельная пред-существующая
проблема, не этой задачи.

TDD, робот замокан, песочница. Приёмка (моя область, чистый прогон):
ModeratingStatusTest 8/8 + StatusMachineTest/RunCampaignJobTest/ExternalIdTest/
RobotRunnerTest 35/35 суммарно; phpstan (Campaign+Job) 0, deptrac 0, pint чисто.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 09:24:09 +03:00
Дмитрий 4ddfa7b0f3 feat реклама за показы: письмо клиенту и колокольчик на ответ Яндекса 2026-07-28 09:23:53 +03:00
Дмитрий 70b421e15b feat реклама за показы: джоб модерации кладёт пояснение Яндекса в ленту кампании целиком 2026-07-28 09:18:45 +03:00
Дмитрий 83753fc049 feat реклама за показы: сервис ленты сообщений и защита от повторов одной и той же причины 2026-07-28 09:12:55 +03:00
Дмитрий 9c332cc26e feat реклама за показы: лента сообщений по кампании — таблица и модель 2026-07-28 09:08:03 +03:00
Дмитрий 73bd36b6ce feat(телеграм-реклама): Этап 3.1 — id кампании МТС хранится рано и при отказе (+ разведка экрана отказа)
Этап 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>
2026-07-28 08:49:01 +03:00
Дмитрий 5211048bce feat(телеграм-реклама): Этап 2 закрыт — робот несёт фактическую стоимость (2.2), билинг МТС подтверждён
Задача 2.0 (разведка) — РЕШЕНА без входа в кабинет: ответ уже был в
находках Фазы 2. Билинг МТС за «показы своей базе» — НАКОПИТЕЛЬНЫЙ
(резерв → списание по факту показов → возврат остатка, экран /payment).
Следствие: списание «по факту» (была задача 2.4) на успехе отправки честно
сделать нельзя — показов ещё нет; перенесено в Этап 3 (опрос завершения).
Этап 2 закрыт составом 2.1 (отмена) + 2.3 (возврат брони) + 2.2.

- 2.2 PHP: RobotResult несёт `actualCostRub` (nullable string) — заготовка,
  чтобы позже (Этап 3) прочитать фактическую стоимость из кабинета и списать
  её с кошелька клиента. Проброшено в fromRobotJson (робот начнёт класть поле
  позже; нет поля → null). Мёртвых фабрик launched()/draftReady() не добавлял.
- 2.2 Node: чистая утилита `parseCost(text)` в cabinet.js — «Стоимость
  кампании от 201,6 ₽» → «201.60» (запятая→точка, разделители тысяч включая
  неразрывный пробел код 160, дробь до 2 знаков без округления, нет числа →
  null). Вынесена отдельной покрытой функцией; к DOM-потоку НЕ подключена
  (селектор строки стоимости подтвердим живьём в Этапе 3).
- Разведка билинга и решение по 2.4 зафиксированы в FLOW-FINDINGS.md; план
  Этапа 2 обновлён (2.0 решён, 2.4 → Этап 3, Этап 2 закрыт).

TDD, робот замокан, тесты на liderra_testing.
Приёмка: Node 40/40 (npm test), Pest RobotResultTest 5/5 + RobotRunnerTest
(потребитель) зелёный, регрессия ClientTg 120/120, phpstan RobotResult.php 0,
pint чисто, deptrac 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:52:56 +03:00
Дмитрий 2e5db6737b feat(телеграм-реклама): Этап 2 (часть) — статус «отменена» и возврат брони при отказе/отмене
Закрыты денежно-независимые куски Этапа 2 (находки #1, часть). Списание
по факту (2.2/2.4) ждёт подтверждения билинг-модели МТС.

- 2.1 Статус кампании «cancelled»: константа STATUS_CANCELLED, переходы
  draft→cancelled и queued→cancelled (отмена только ДО запуска); cancelled —
  терминальный. Из running/терминальных отмена запрещена. Миграция не нужна —
  status это string(16) без CHECK-констрейнта.
- 2.3 Возврат брони (release), иначе резерв кошелька залипал (находка #1):
  • при отказе робота (finalize) — release после записи FAILED, отдельным
    tenantTx, чтобы сбой возврата не откатил статус;
  • при перманентном сбое джоба (failed) — тоже release;
  • новый endpoint POST /api/telegram/campaigns/{id}/cancel: draft/queued →
    cancelled с возвратом брони; из running/терминальных — 422.
  Возврат гейтится !sandbox (в песочнице заморозки не было). Ключи release
  совпадают с freeze в launch ('telegram','campaign',id). RLS: денежная
  операция в джобе идёт под SET LOCAL app.current_tenant_id (образец
  ChargeTgNameFeeJob).

Возврат при rejected здесь НЕ трогаем — его выставляет опросчик модерации
(Этап 3, задача 3.4).

TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Pest ClientTg RefundOnFail 5/5 + регрессия соседей (32/32 суммарно),
deptrac 0, pint чисто, phpstan по боевым файлам (джоб/контроллер) 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:28:16 +03:00
Дмитрий af12b1ceb4 fix реклама за показы: предел веса картинки проверен по-настоящему, обход модерации не срывается целиком, робот не несёт токен на чужой адрес
Мелочи приёмочного листа v12 §8. Каждая правка с тестом; где защита уже стояла
в коде — тест проверен вырезанием этой защиты.

Предел веса картинки. Две прежние проверки были пустышками: сравнивали константу
саму с собой и с тем же числом в ответе сервера. Вырезание правила max: оставляло
обе зелёными. Настоящий тест грузит перевес и ждёт отказа — это четвёртая найденная
пустышка за ветку.

Обход модерации. Объявление без статуса и причина отказа длиннее колонки роняли
запись в базу ВНЕ защиты, и обход обрывался на середине: остальные клиенты не узнавали,
приняли их рекламу или отклонили, а деньги за отклонённый набор не возвращались.
Запись ответа теперь под той же защитой, что и сеть; пустой статус не пишем вовсе,
причину храним обрезанной.

Робот. Адрес файла приходил в ответе сервера, а шли по нему со своим токеном без
всякой сверки. Теперь адрес обязан вести на портал. Папка снимков экрана росла
бесконечно, а на снимках видны логин и остаток счёта — старше двух недель убираются.

Ещё: порядок посредников служебного канала — токен раньше служебного соединения;
BannerGenerator больше не отдаёт молча файл тяжелее предела и берёт предел из общей
константы; нулевой номер креатива ловится намеренно, а не случайно нестрогим сравнением.

Портал 300/300, робот 60/60. Мест снятия заморозки денег по-прежнему четыре.

Не тронуто намеренно: цена за 1000 показов и бюджет приходят от клиента — но это
видимое поле мастера и принятое продуктовое решение, а не недосмотр. Решает владелец.
2026-07-28 07:21:02 +03:00
Дмитрий b3a86e69ad feat(телеграм-реклама): Этап 1 — безопасность и защита входа модуля «по своей базе»
Закрыты 5 находок аудита (безопасность и валидация входа, деньги не задеты):

- #11 ПДн-скрины робота: полноэкранный скриншот кабинета МТС (мог содержать
  телефоны базы, 152-ФЗ) больше не снимается и не уходит письмом по умолчанию —
  только по явному TG_DEBUG_SHOTS. Чистый хелпер src/shots.js.
- #12 стоп-лист opt-out теперь нормализуется при сравнении (8XXXX / 10-значные
  формы вычищаются), сравнение по голому 7XXXXXXXXXX.
- #13 верхние пределы входа: phones max:200000, phones.* max:32, audience_days
  max:365; store возвращает dropped_count (сколько номеров не распозналось).
- #14 идемпотентность запуска: Фаза A джоба читает кампанию с lockForUpdate —
  два воркера сериализуются на блокировке строки (защита от гонки).
- #2 предстартовый гейт аудитории: в боевом режиме launch НЕ бронирует деньги
  под кампанию с <367 / пустой аудиторией (иначе бронь залипала бы). Порог —
  config client_tg.auto_batch_threshold (367). В песочнице гейта нет.

TDD, робот в тестах замокан, тесты на liderra_testing (7999… номера).
Приёмка: Node 28/28, Pest ClientTg 105/105, phpstan 0, pint чисто, deptrac 0.
Обновлён CampaignApiTest (тест «денег не хватает → 409» засеян ≥367 контактами,
чтобы дойти до проверки денег после нового гейта).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 06:37:25 +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
Дмитрий 542b7d2d22 fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится
Р6 — задание, брошенное «в работе», держало очередь для всех клиентов навсегда.
Причина сбоя обрезается до 900 знаков, иначе портал отвергал отчёт о самом частом
виде поломки. Провал отчёта больше не глотается молча — едет в письмо человеку.
Приём отчёта ловит любую ошибку и закрывает задание сбойным вместо 500 роботу.
Новый сторож creative-jobs:reap каждые 10 минут разбирает пробку.

Р7 — обрыв на докладе «готово» объявлял провалом уже сделанную работу. Доклад
вынесен из-под обработчика сбоя, повторяется трижды, при неудаче только письмо.
Осечка ухода со страницы после «Создать» больше не считается провалом загрузки.

Р8 — пустой список размеров считался успехом, и робот заливал одни и те же файлы
по кругу, оставляя мусор в живом кабинете. Теперь громкий отказ до похода в Яндекс.

Портал 274/274, робот 41/41. Все защиты проверены вырезанием.
Выходов снятия заморозки денег по-прежнему четыре.
2026-07-27 20:42:23 +03:00
Дмитрий cc985e07de feat(телеграм-реклама): экран «реклама отклонена» + закалка робота против глюков МТС
Клиентский модуль «Реклама в Телеграме по своей базе» (робот-в-браузере, МТС Маркетолог).

Задача 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>
2026-07-27 20:10:10 +03:00
Дмитрий b8f75b2a0c fix реклама за показы: файл робота только по своему заданию, замок на баннерах, защита от двойного запуска
Р3 хвост. Адрес файла баннера стал /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file.
Раньше портал подставлял «какое-нибудь задание в работе» — защита выдачи чужих картинок
держалась на внешнем условии, а не на самом запросе. Робота править не пришлось: он берёт
адрес из ответа портала как есть. Запись v9.08 в журнал схемы про частичный уникальный
индекс; rls-reviewer по миграции — GO.

Р4. Замок на наборе баннеров после заведения кампании в Яндексе: перезаливка, включение
и удаление отдают 409 по тому же признаку yandex_campaign_id, что и замок на параметрах.
Без него в портале была новая картинка, а в Яндексе крутилась старая, а удаление строки
с номером объявления заставляло возобновление завести второе объявление того же размера —
старое продолжало крутиться за деньги клиента. Баннер с номером объявления не удаляется
никогда.

Р5. Захват кампании под запуск: новый промежуточный статус launching, перевод под замком
строки в транзакции. Два одновременных нажатия «запустить» проходили проверку статуса оба
и заводили две CPM-кампании при одной заморозке денег. На любой ошибке прежний статус
возвращается, номера созданных в Яндексе сущностей уцелевают — возобновляемый запуск
не тронут. Брошенный захват старше пятнадцати минут перехватывается.

Тесты: портал 266/266, робот 35/35. Все новые защиты проверены вырезанием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:06:57 +03:00
Дмитрий d26716ed90 fix реклама за показы: срок показа закрывает кампанию, отчёт робота только по заданию в работе, гонка выдачи заданий
Р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>
2026-07-27 19:36:03 +03:00
Дмитрий 5b04d751c0 feat(телеграм-модуль): авто-режим, своё имя + помесячная оплата, админ-тарифы
Сессия 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>
2026-07-27 18:41:33 +03:00