Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке
застрявших оказались три кампании, а не одна — и у двух из них заморозку уже
отпустили, денег за ними не осталось.
Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽».
Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы,
что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная
галочка над невыполненной работой.
Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь
список. Когда её нет — так и написано, и кнопка меняет обещание на «просто
закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с
баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот
случай — кабинету уплачено 180 ₽, а заморозки уже нет.
Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Кампания, брошенная роботом на полпути, попадала в needs_review или
draft_ready — статусы, из которых не вело ни одного перехода. Замороженные
деньги клиента запирались навсегда, снять их мог только программист правкой
боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14.
Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер
кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает
сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено;
иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой
заморозку и заводили.
Заодно: клиенту больше не показывают внутренние адреса и команды запуска —
причина отказа переводится на человеческий язык. Портал перестал принимать
пустой отчёт робота как успешный.
Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Приёмка вскрыла, что первый заход воспроизвёл ту же беду на другом основании.
1. Невод по тексту не мог найти НИЧЕГО по построению.
`obzvon_materialy_klienta.transcript` не пишет никто: расшифровщик живёт в
З-4.2, волна 4. Значит сводка честно говорила "материалы клиента=0", пока
голос человека лежал на диске 30 дней. Теперь портал считает отдельным числом
записи, которые проверить НЕЧЕМ - звук жив, расшифровки нет и не было, - и
говорит это тревожной строкой.
🔴 Условие уточнено против предложенного: добавлено transcript_deleted_at IS
NULL. Без него строка, у которой расшифровку стёрли мы сами, а файл убрать не
смогли, попадала бы в оба числа сразу. Доказано надрезом.
2. Изоляция клиентов не сторожилась ничем. Соединение обходит защиту строк, значит
отбор по клиенту в коде - ЕДИНСТВЕННЫЙ замок. Сняв его, приёмщик оставил все
13 сторожей зелёными. Заведены сторожа на все три таблицы.
3. Своя находка того же класса: читатель флага сверял телефон точными написаниями
с колонкой, которую заполняет ЧЕЛОВЕК руками. На записи "8 (900) 123-45-67"
он молча отвечал "звонить можно" тому, кто потребовал прекратить обработку.
Сверка переведена на хвост из десяти цифр - и там, и в переходнике.
4. Экран админки показывал зелёную галочку "выполнено" и пустое поле
"Webhook-логов", а про обзвон и про нестёртые файлы молчал. Это видимая
половина той же неправды: тот, кто жмёт кнопку, и есть тот, кто обязан пойти
проверить руками. Экран называет обзвон поимённо, при нестёртых файлах и
непроверяемых записях галочки нет вовсе - вместо неё тревога. Мёртвое поле
убрано вместе с полем в типе ответа.
Сторожа: 13 -> 19 на сервере, плюс 5 экранных. Каждый показан красным семью
надрезами по коду.
Отчёт: docs/superpowers/priyomka/stroyka-2/z-2-5-otchyot-2026-08-05.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Замер боевого перед выкатом СМС-модуля показал мину: на liderra.ru сейчас
работает код ветки fix/tg-zagolovok-obyavleniya — 33 записи телеграм-рекламы,
которых не было ни в main, ни в рабочей ветке. Сверено слепками файлов:
config/client_tg.php и TelegramTariffService.php на бою совпадают с их версией
и расходятся с нашей. Экраны портала собираются одним куском, поэтому выкат
СМС в прежнем виде откатил бы их живую рекламу назад. Решение владельца —
сперва забрать их работу к себе.
Разрешено пять склеек, все — сохранением обеих сторон:
- Tariff.php: их пояснение про закупочную цену плюс наша строка для подсказчика
типов;
- phpstan-baseline.neon: взята наша вычищенная версия. Их 260 строк заметания
не возвращены — статанализ после сведения дал 0 без них, потому что чинили мы
не baseline, а сам механизм, и он вылечил их новые тесты тоже;
- advertising-telegram-view.spec.ts: наш типизированный мок оставлен;
- CHANGELOG_schema.md: столкновения номеров НЕ было — у нас v9.33/v9.34, у них
v9.64/v9.65. Обе пары сохранены, ничего не двигали; в файл дописано, что дыра
v9.35–v9.63 это след их завышенного замера, а не потерянные записи;
- STATUS.md: служебный файл наблюдателя, взят свежий.
Что сведение вскрыло дополнительно:
- их сторож значков поймал НАШИ три имени с экранов СМС — mdi-file-sign,
mdi-account-badge-outline, mdi-file-download-outline в карте Lucide
отсутствовали и на бою рисовались бы вопросом в кружке. Добавлены по смыслу;
- проверка типов фронта: 1 ошибка стала 0. Образец имени отправителя в тесте
админки не задавал два поля, и Partial подмешивал в них undefined.
Проверено после сведения: PHP 4614 тестов, 4610 зелёных, 4 пропущено,
0 падений; экраны 250 файлов, 2018 зелёных, 3 пропущено, 0 падений;
статанализ 0 через composer stan; проверка типов 0.
Унаследованное, не мной внесённое и не тронутое: линтер фронта показывает
1 замечание на неразрывный пробел в advertising-sms-view.spec.ts — знак там
нужен по смыслу проверки, было до сведения.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Мы продавали дешевле, чем покупали. Тариф давал скидку за объём — 0.45 → 0.36 ₽
за показ, — а у МТС такой скидки нет: прайс кабинета, стр. 13, берёт 0,48 ₽ за
показ плоско. Скидку давали мы, а нам её не давал никто: на объёме от 50 000
наценка 1.40 превращалась в 5%.
Второе. Кабинет показывает CPM БЕЗ НДС — колонка списка так и названа. Счёт
кампании 2231134 сошёлся: 420 × 400 ₽/1000 × 1,2 = 201,60 ₽. Мы ИП на УСН, НДС
не возмещается, это расход. Себестоимость с НДС: 0.480 без медиа, 0.720 с
картинкой, 0.816 с видео.
Третье. Вид медиа до расчёта не доходил вовсе — объявление с видео продавалось
по цене объявления без картинки. На видео уходили в минус до 176 ₽ с тысячи, и
портал нигде свою цену с ценой МТС не сравнивал. Песочница выключена с 02.08 —
деньги живые.
Что сделано:
- миграция client_tg_cena_po_media: min_qty → media_kind, точность цены 6,2 →
6,3, три строки вместо пяти ступеней, media_kind на кампании и авто-правиле;
- вид медиа определяется по СОДЕРЖИМОМУ файла, а не по расширению имени;
- экран админки переделан: три фиксированные строки по видам медиа, рядом цена
за тысячу для сверки с кабинетом, добавлять и удалять нечего;
- запись v9.65 в журнале схемы.
Замеры этой смены: телеграм-модуль 313 тестов 0 падений, экраны 242 файла
1841 тест 0 падений, статанализ 0, форматтер чисто. Проверка типов — 6 ошибок,
все в чужих файлах, были до нас.
Тест экрана тарифов проверен вырезанием, а не только зелёным: сломал передачу
media_kind — красный; сломал расчёт цены за тысячу — красный.
Осталось незакрытым: числа 0.720 и 0.816 — вывод по правилу «кабинет пишет без
НДС», а не замер. Счёт кампании с картинкой живьём не снимали, шаг «Стоимость»
недостижим без загрузки живых номеров. Замерите — правится одной строкой.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.
Девять столкновений, каждое разобрано по существу.
Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.
Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.
Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.
Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.
Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.
СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.
Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
- 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
перенесены из рабочей ветки, где владелец их уже принял;
- остальные 42 - свои, в новом коде ветки, и починены по существу:
задвоенный ключ массива в трёх тестах (след копирования - комментарий
оторвался от своей строки), врущие описания двух помощников (PHP сам
делает из ключа-номера число), сужение типа возврата, прятавшее от
анализатора свойства подставного отправителя, лишний знак вопроса и
четыре бесполезных перенумерования списка.
- Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
поимённо и покраснел; убрал - ноль.
Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.
Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.
Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.
Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.
NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Находка приёмки Этапа 5. Сверка считала счёт оператора «известным», если отчиталось
хотя бы ОДНО сообщение из всей рассылки, а разницу брала против нашего расхода по
ВСЕМ. Живой замер на стенде: оператор отчитался по 10 сообщениям из 120 — экран
написал «наш расход 360.00 руб, счёт оператора 60.00 руб, разница минус 300.00 руб».
Человек прочитает это как «МТС недосчитал 300 рублей». Правда другая: МТС ещё не
отчитался по 110 сообщениям. Отчёты приходят постепенно, опрос ходит раз в десять
минут — значит такое состояние было бы у КАЖДОЙ свежей рассылки.
Это тот же класс, что уже ловили на вебхуке поставщика: сторож, не умеющий отличить
«потеряно» от «ещё в пути», штампует ложные тревоги и заставляет чинить несломанное.
Что сделано:
- разница считается от нашего расхода ПО ОТЧИТАННОМУ, то есть сравнимое со
сравнимым. Для этого запрос отдельно складывает наши части только по тем
сообщениям, за которые оператор назвал цену;
- «наш расход» слева остался прежним — это сколько мы должны за всю рассылку, и
число полезное. Рядом с разницей экран пишет охват: «оператор отчитался по 10
из 120». Без охвата разница непонятна: не видно, по всей ли рассылке она;
- охват пишется ТОЛЬКО когда отчитались не по всем, иначе строка шумела бы всегда;
- настоящее расхождение по деньгам видно и при неполном отчёте — ждать полного
отчёта, чтобы заметить, что оператор считает дороже, было бы хуже исходной беды.
Порядок работы соблюдён. Четыре теста сервера и два теста экрана написаны ДО правки
и покраснели на отсутствующих полях. Каждый доказан вырезом, вырезов четыре:
вернул разницу к расходу по всей рассылке - покраснели 2; убрал охват из ответа -
покраснели 3; убрал строку охвата с экрана - покраснел 1; показал охват всегда -
покраснел другой 1. Это пара: один тест стережёт «видно», второй «не шумит».
Прогоны: модуль 387 из 387, фронт 222 файла и 1722 зелёных при 3 намеренно
пропущенных, формат чист, типы - 5 ошибок и до правки, и после, все в чужих файлах.
Статанализ добавил 4 замечания одного ложного класса: анализатор не понимает $this
внутри тестов Pest и не видит getJson. Доказательство ложности прямое - эти самые
тесты проходят, не будь метода, они бы падали.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Строка листа 5.7. В админке «СМС» появился блок «Сверка расчётов с оператором»: по каждой
отправленной рассылке видно, сколько частей насчитали мы и сколько оператор, сколько
сообщений разошлись, наш расход, счёт оператора и разница. Клиенту это не показывается
вовсе (решение В-201) — он платит по своему тарифу, наш расход перед оператором не его дело.
Главное решение здесь — отсутствие числа и ноль это РАЗНЫЕ состояния. Экран пишет «цена
канала не задана», «оператор цену не сообщил», «не спрашивали», «сверять нечем» и никогда
не рисует 0 ₽ вместо неизвестного. Иначе владелец видел бы идеальную сходимость там, где
сверять нечем — ровно тот молчаливый сбой, что стоил четырёх поломок 20.07. Разница
считается только когда известны оба числа.
Порядок работы задала живая проба, а не код. Шесть живых сообщений с боевого (разрешение
и номера дал владелец) с перебором по одному признаку: МТС-номер и Т2-номер, одна часть и
две, три разных имени отправителя, смешанный и чисто русский текст. Все шесть — «не
отправлено», цена 0, отказ в ту же секунду, что и приём. Владелец проверил кабинет: баланс
5010 ₽, имя живое. Значит вывод из памяти проекта «пустой счёт» опровергнут, причина на
стороне оператора (В-235, В-237) и требует разбора с их поддержкой.
Попутно вторично и жёстче подтвердилось В-212: оператор принял даже номер Теле2, которого
наш канал не обслуживает. «Принял» не значит ничего — по-старому портал записал бы
«отправлено» и списал бы деньги за сообщения, которых нет.
Что проба дала положительного: оператор считает ЧАСТИ ровно как мы — 1 на короткое, 2 на
длинное в 118 знаков. На этом сверка по частям и построена, деньги ей не нужны.
Строка 5.7 сужена честно и не молча (В-236): денежная половина построена, но живьём не
доказана — цены нет с обеих сторон. У оператора 0, а у нас цена канала на бою не задана
вовсе (В-234). Дозакрыть можно двумя вещами: числом из договора и одним реально
отправленным сообщением с ненулевой ценой.
План велел править AdminSmsController — такого файла нет вовсе (В-231, четвёртый раз за
проект). Сверка живёт отдельным распорядителем: она ни ценам, ни отказам, ни именам
отправителя не родня.
Тесты: 9 на сервере, 6 на экране. Все проверены вырезом — 15 вырезов, каждый покрасил
именно свои тесты. Один вырез не сработал, и опять ошибалось моё ожидание, а не код
(В-239): база сама считает сравнение с пустотой «неизвестным», поэтому страж оказался
лишним; заменён на вырез с настоящей ошибкой этого места.
Живой прогон в браузере парный: без заданной цены канала — «цена канала не задана» и
«сверять нечем»; с ценой 3 ₽ за часть — 9.00 ₽ против 12.00 ₽, разница 3.00 ₽, а
расхождение по частям (3 против 4) выделено красным. Стенд возвращён.
Статанализ поймал настоящую ошибку: сводные числа читались как поля модели сообщения,
которых у неё нет. Починено — итоги берутся голыми строками, а не моделями.
Строка листа 4.6 состояла из двух половин, и вторая («десятки тысяч
проходят и не падают») не выполнялась вовсе: каждый номер писался
отдельным запросом — 20 000 номеров стоили 25 278 запросов и 41 секунду.
Теперь пишем пачками по 1000 одним upsert: 23 запроса и 3.5 секунды.
Оплаченный ДаДатой оператор при повторной загрузке не стирается, дубли
внутри одной загрузки не роняют её, база не задваивается.
Потолок: колонка client_sms_settings.max_upload_phones (миграция
2026_08_01_100800, схема v9.20), по умолчанию 50 000, правится владельцем
в админке в границах 1 000…100 000. Сверх потолка загрузка отклоняется
целиком — частично загруженная база хуже незагруженной — и человек видит
оба числа. Экран говорит потолок ДО загрузки, числом с сервера.
Потолок спрашивается ПЕРЕД построчной проверкой номеров: иначе отказ на
50 001 номере занимал 20 секунд (замерено живым прогоном), а при верхней
границе запрос успел бы умереть по сроку жизни.
Ответ GET /api/sms/contacts стал объектом {items, max_upload_phones};
мёртвое поле contacts из ответа загрузки убрано.
Проверено: 14 серверных тестов, 2 фронтовых, 8 вырезов (каждый покраснел
там, где вырезан), живой прогон под боевой ролью crm_app_user и в браузере.
Co-Authored-By: Claude Opus 5 <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>
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или
виртуального оператора в рассылку не берётся, и человек видит честную причину,
а не молчаливую пропажу.
Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный
канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём.
Список — настройкой, а не в коде: client_sms_settings.allowed_operators
(схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию.
Правило живёт в одном месте — AllowedSmsOperators.
Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок
и своей базы оператор известен в момент заказа, у номеров, вписанных руками,
его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится
уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали».
Плата за имя не тронута (В-150): в коде два похожих списка операторов, и
связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей.
Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём
только по МТС)» — универсальный канал возит всех.
Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка
24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент
ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн».
ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние,
vue-tsc 5 чужих давних, pint чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Строка листа 4.3, Этап 4 Task 2.
Срок, после которого сторож считает рассылку зависшей, переехал из кода в
админку «СМС» — рядом с границами окна отправки. Поле подписано человеческими
словами и объясняет последствие: портал сперва попробует рассылку дожать, потом
остановит и вернёт клиенту замороженные деньги, а ту, что честно ждёт утра
получателей, не тронет.
Границы 5…1440 минут — не вкусовщина. Ниже пяти сторож срывал бы рассылки,
которые просто идут медленно: проверка средств и запись в журнал на каждый номер
занимают время. Выше суток чужие деньги висели бы замороженными дольше, чем
клиент вообще помнит про эту рассылку. Отказы написаны словами, а не кодами.
Поле НЕобязательное — как и границы окна: этот адрес зовут и те, кому нужны
только настройки имени отправителя, и ломать им запросы права не имеем.
(План велел сделать поле обязательным — это была ошибка плана, журнал В-139.)
Проверено вырезанием, два выреза, оба вернуты:
— убрал границы на сервере → покраснели оба теста про границы;
— убрал отправку срока с экрана → покраснели три фронтовых, включая новый.
Живой прогон в браузере: выставил 120 → сохранилось → пережило перезагрузку
страницы; ввёл 2 минуты → отказ «Слишком мало: рассылка может идти медленно,
и сторож срывал бы живые. Ставьте хотя бы 5 минут», в базу не легло. Вернул 60.
Прогоны: затронутые тесты 32/32, фронт целиком 1678 + 3 намеренно пропущенных
(было 1676, мои +2; одна ошибка — та же чужая давняя), vue-tsc ровно 8 чужих
давних в 6 файлах, pint чисто.
Задача 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>
Этап 3 «Время и цена», Task 1. Закрыты строки листа Н.3 и 3.7.
Поведение рассылки ещё НЕ меняется — этим займётся Task 2. Сейчас заведено то,
на чём оно будет стоять, и заведено так, чтобы правило нельзя было размножить.
1. Справочник часовых поясов (RegionTimezoneMap). 89 субъектов РФ в том же порядке,
что и справочник имён; сторож-тест сверяет составы, чтобы справочники не разъехались.
Отдельный тест на ловушку: код субъекта у нас НЕ автомобильный — 77 это Тюменская
область, а Москва 82. Неизвестный код и непонятная строка от ДаДаты дают «не знаю»,
а не ноль: ноль означал бы Гринвич, то есть тихую подмену Камчатки Лондоном.
2. Единственный дом правила 10–20 (SmsQuietHours): можно ли отдавать сейчас, когда
откроется окно, осмысленно ли такое окно. Границы читаются из настроек один раз
на объект — на 20 000 номеров иначе был бы 20 000-й запрос к базе.
3. Границы окна в общих настройках: миграция добавляет две колонки со значениями 10 и 20.
Защита от повторного запуска пошаговая — прерванная ручная подача SQL на бою не должна
оставить вторую колонку несозданной (урок В-80). Прав не требует: колонки наследуют
привилегии таблицы. Запись схемы v9.12.
4. Админка «СМС»: два поля «Отправляем с / по» и объяснение, что часы — по местному времени
получателя и клиент их не настраивает. Окно наизнанку «с 20 до 10» это отправка всю ночь,
поэтому сервер его не принимает и говорит человеку почему. Проверяется ПОЛУЧИВШЕЕСЯ окно,
а не присланные поля: правка одной границы тоже могла его вывернуть — журнал В-91.
Прогоны: СМС 186/186 (было 171, 15 новых тестов), приём лидов 17/17, фронт 1658 зелёных
и 3 намеренно пропущенных, phpstan по своим файлам 0, pint чисто, проверка типов без новых ошибок.
Вырезанием проверено дважды: убрал проверку окна — покраснели два теста; убрал чтение границ
с сервера на экране — покраснел фронтовый тест. Живьём: окно 11–19 сохранилось и пережило
перезагрузку, «с 20 до 10» отклонено с человеческим текстом, вернул 10–20.
Попутный урок В-92: два моих же новых теста сперва зеленели по неверной причине — ругань
приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо
и проверяют, за какое поле ругаются.
Вкладка «Не писать этим» отдельной панелью SmsOptoutsPanel.vue: номер руками,
пачкой из файла, удаление; непонятые строки показаны образцами, а не молча.
Кнопка «Остановить» видна, пока рассылка в очереди, идёт или ждёт утра; после
остановки строка показывает честный итог «Ушло N из M, списано X ₽» — деньги
фактические, а не смета.
Ключ заказа crypto.randomUUID() уходит с каждой отправкой и меняется после
успеха. На ответ сервера «похоже, это повтор» экран задаёт вопрос словами
сервера и повторяет только по согласию человека.
В админке раздел «Общий стоп-лист»: список, внесение с причиной, удаление.
Отказ получателя (страница по ссылке и приписка в тексте) в экранах
отсутствует — отменён владельцем, см. В-30 приёмочного листа.
Co-Authored-By: Claude Opus 5 (1M context) <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>
Задача 8e. SaaS-оператор видит кампании в статусе queued по всем тенантам и
вписывает номер оформленного в конструкторе Яндекса адаптивного креатива
yandex_creative_id, после чего кампанию можно запускать. Два эндпоинта
AdminAdvertisingController campaignsAwaiting и setCampaignCreative под
saas-admin+admin-db, карточка в AdminAdvertisingView, api-функции и типы.
🔴 Запись строго через сырой DB::table update только колонки yandex_creative_id
Eloquent-билдер добавил бы updated_at, а колоночный GRANT у crm_admin_user
разрешает писать лишь эту колонку тесты идут под суперюзером и это не ловят.
RLS не трогаем ad_campaigns уже покрыт srv_bypass и грантами.
Бэкенд AdminAd 19/19, реклама-модуль 195/195, фронт админки 11/11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Задача 8b. Админ-экран «Реклама: расход и маржа» переведён со старой модели
наценка 30% делением на единую модель показов наценка 40% вычитанием, как в
CampaignImpressionCharger. yandex_cost = client_spend умножить на долю
1 минус ad_margin_percent/100. Поле ответа markup_percent переименовано в
ad_margin_percent, фронт-вью и тип обновлены.
Мёртвый класс AdMarkup и его тест удалены полностью. Колонка ad_settings
markup_percent осталась в схеме, но больше не читается. Тесты: бэк
AdminAdvertisingSpend 6/6, реклама-модуль 189/189, фронт админки 7/7.
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>
Имя у оператора регистрирует Лидерра от лица клиента, поэтому от клиента два
скана: подписанное разрешение и документ-основание. Оба обязательны, галочка
согласия убрана.
- Бланк разрешения — PDF по нашему образцу «Разрешение-домен УНИВЕРСАЛЬНОЕ»:
Правообладатель = клиент, Пользователь = наш ИП из legal_entities is_default.
4 вида имени домен/юрлицо/ИП/товарный знак — у каждого своё основание права.
- Физлицо: домен часто на физлицо — гейт по ФИО, паспорт вписывается от руки,
паспорт не храним 152-ФЗ.
- Две колонки client_sms_senders: doc_* документ-основание, consent_doc_*
подписанное разрешение.
- Админка: скачивание обоих сканов, вид .../document/basis и .../document/consent.
- Гейт реквизитов requisites_ready; эндпоинт GET /api/sms/sender/consent-form.
Тесты: ClientSms backend 95, фронт СМС 43, pint чисто.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Клиент: карточка имени отправителя (статусы pending/active/suspended/rejected,
диалог заказа с согласием и платой, отключение) + вкладка «Авто-СМС» (тумблер+текст).
Админ: экран «СМС: тарифы и имена» — правка ступеней, платы за имя/грейса, очередь
подтверждения имён (approve/reject/disable); роут /admin/sms + пункт AdminLayout.
API-обёртки в client-sms.ts и admin.ts (cookie-сессия).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.
Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Закрывает дыру #4 аудита журналирования. Объём по выбору заказчика — МИНИМУМ:
✅ Админ-API + кнопка в админке для удаления ПДн субъекта
✅ Сервис анонимизации (users + supplier_leads + deals + webhook_log)
✅ Журнал факта удаления в pd_processing_log
❌ БЕЗ формы самообслуживания на стороне субъекта
❌ БЕЗ email-подтверждения
❌ БЕЗ 30-дневного SLA (trigger deadline_at уже в схеме)
Что добавлено:
* Eloquent-модель `App\Models\PdSubjectRequest` (таблица уже была в схеме)
* Сервис `App\Services\Pd\PdErasureService::eraseSubject()`:
- cross-tenant через pgsql_supplier (BYPASSRLS)
- транзакционно (rollback при ошибке)
- users: email→erased-{id}@deleted.local, first_name→Удалено, last_name→null,
phone→+7000{id}
- supplier_leads: phone→+7000XXXXXXX, raw_payload→{erased:true}
- deals: phone→+7000XXXXXXX, contact_name→Удалено (только если есть phone)
- webhook_log: batched UPDATE по 500, raw_payload→{erased,erased_at}
- pd_processing_log запись action=deleted за каждого user/lead с
actor_admin_user_id (hash-chain audit_chain_hash триггером сам подписывает)
- При requestId — pd_subject_requests SET status=completed, completed_at,
response_text счёт
* Контроллер `AdminPdSubjectRequestsController`: index/show/store/executeErasure
* Маршруты под middleware(saas-admin): GET/POST /api/admin/pd-subject-requests,
GET /{id}, POST /{id}/erase
* Vue: `AdminPdSubjectRequestsView` (Quiet Luxury, таблица + диалог создания +
кнопка Анонимизировать для request_type=deletion); ESLint требует
v-slot:[`item.X`]= вместо #item.X для динамических slot-имён с точкой
* Пункт меню в AdminLayout.vue + route /admin/pd-subject-requests
NB: реальная схема — users.first_name/last_name/phone/email; supplier_leads
имеет только phone (нет contact_*); deals имеет phone+contact_name (нет
contact_email); webhook_log JSONB. PdErasureService адаптирован под факт.
Тесты: 12/12 passed (63 assertions, ~2.6s) — index pagination, store +
deadline trigger (+30 дней), eraseSubject анонимизация user/lead/deal/log,
pd_processing_log запись, request status→completed, отклонение
не-deletion типов, gate saas-admin, InvalidArgumentException.
Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#4).
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null.
Теперь AdminTenantsView показывает реальную колонку MRR.
Backend (AdminTenantsController::index):
- Добавлено tariff_plans.price_monthly as tariff_price_monthly в select.
- mrr_rub в response: price_monthly (string) если не-trial; иначе null.
- Aggregate-формат как у /admin/billing — string чтобы decimal не терял
точность при передаче через JSON.
Pest +3 (AdminTenantsIndexTest):
- mrr_rub='990.00' для активного тарифа не-trial.
- mrr_rub=null для trial (даже если тариф есть).
- mrr_rub=null если current_tariff_id отсутствует.
Frontend:
- ApiAdminTenant.mrr_rub: string | null в типе.
- mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded
null из v1.66).
- AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими
₽-полями.
Vitest +2:
- mrr_rub строка → number.
- mrr_rub=null → mrrRub null.
PHPStan baseline регенерирован. cspell-glossary +консистентности.
Регресс:
- Lint+type-check+format passed.
- Vitest 313/313 за 18.83 сек (+2 от 311).
- Vite build 947 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion).
Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>