Commit Graph

237 Commits

Author SHA1 Message Date
Дмитрий 337f6b9897 feat телеграм: дата старта показов, признак живости системы и человеческий язык ошибок
Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной
даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался
ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня.
Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом —
ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on
и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете:
три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821.
Мастер перестал молчать о том, что день начала ставит кабинет, а не мы.

Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад».
Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой
не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине
владелец 36 часов не знал, что робот вообще не может войти в кабинет.

Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше
1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить»
называет причину и шаг, куда вернуться, а не гаснет молча.

Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе
защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого
теста. Сторожа доказаны вырезанием.

Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:33:35 +03:00
Дмитрий f83b845658 fix телеграм: экран разбора показывал смету вместо живых денег
Поймано на боевых данных 06.08.2026, ДО первого нажатия кнопки. В списке
застрявших оказались три кампании, а не одна — и у двух из них заморозку уже
отпустили, денег за ними не осталось.

Экран же показывал смету и подписывал её «Заморожено у клиента: 268,80 ₽».
Владелец нажал бы «вернуть деньги», не вернулось бы ничего, а он считал бы,
что вернул. Обещать возврат того, чего нет, — то же враньё, что зелёная
галочка над невыполненной работой.

Теперь в списке идёт живая заморозка, посчитанная одним запросом на весь
список. Когда её нет — так и написано, и кнопка меняет обещание на «просто
закрыть кампанию». Отдельно предупреждаем, когда списание пойдёт прямо с
баланса клиента, а не из отложенного: у кампании №10 на боевом ровно этот
случай — кабинету уплачено 180 ₽, а заморозки уже нет.

Проверено: модуль 355 тестов, экраны 16 тестов, статанализ 0, типы чисты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 15:30:18 +03:00
Дмитрий 13ce301ab3 feat телеграм: разбор застрявших кампаний и человеческий язык отказов
Кампания, брошенная роботом на полпути, попадала в needs_review или
draft_ready — статусы, из которых не вело ни одного перехода. Замороженные
деньги клиента запирались навсегда, снять их мог только программист правкой
боевой базы. В бою 06.08.2026 так заперло 268,80 ₽ по кампании №14.

Теперь в админке есть карточка «Застрявшие кампании»: владелец видит номер
кампании в кабинете МТС, запертую сумму и уже уплаченную МТС сумму — и решает
сам. Кнопка «списать по факту» показывается ТОЛЬКО когда МТС уже уплачено;
иначе списывать было бы нечего, кроме сметы — ровно та беда, ради которой
заморозку и заводили.

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

Проверено: модуль 354 теста, экраны 13 тестов, статанализ 0 ошибок.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:56:50 +03:00
Дмитрий a709efdee4 feat(кошелёк): две колонки, период, разрез по кампаниям, расшифровка заморозки
Владелец 06.08: "кошелёк убогий и неинформативный". Отметил пять слабых мест
из шести. Не отметил только прогноз "надолго ли хватит" - он пополняет по
факту, а не по остатку.

Что теперь на экране:
- слева липкая колонка денег: свободно крупно, и под ней РАСШИФРОВКА
  заморозки - какая кампания, сколько заперто, когда вернётся. Раньше стояло
  голое "Заморожено 2 507 руб" без единого слова, под что;
- период: сегодня / вчера / 7 дней / 30 дней / свои даты, живёт в адресе
  страницы;
- столбики расхода по дням, цвет по каналу, своим CSS без библиотеки;
- таблица "Куда ушли" - строка на кампанию с суммой и долей, клик отбирает
  ленту;
- закладки по каналам сохранены, но теперь считаются за выбранный период;
- лента разбита по дням с итогом за день и постраничной догрузкой.

Сервер: две новые сборки RaskryitieZamorozki и OtchyotKoshelka, новая ручка
/api/advertising/wallet/report, лента получила отбор по датам, каналу и
кампании и листание по ключу вместо смещения. Расчёт списаний, заморозки и
возврата по сроку НЕ тронут - правка только про показ.

Найдено по дороге и закрыто:
- чтение отчёта обёрнуто контекстом построчной защиты. Без этого на боевом
  запрос вернул бы НОЛЬ строк молча: местная база под суперпользователем
  защиту обходит, и мы бы увидели это только у клиента;
- сторож на число запросов мог зеленеть БЕЗ самой ручки - на любой неизвестный
  путь портал отвечает страницей с кодом 200. Усилен и проверен вырезанием
  маршрута;
- общий форматтер срезал хвостовой ноль: "833,50" превращалось в "833,5". На
  это независимо наступили три правки подряд, каждая завела свою копию.
  Сведено в formatExact.

Границы суток и группировка по дням считаются ПО МОСКВЕ, а не по Гринвичу.
Сторож взят с живого боевого: запись 69, 2026-08-05 21:00:24 UTC - для
человека это 6 августа. Наивные версии всех трёх мест были написаны нарочно
и увидены красными, прежде чем чинились.

Старые сторожа не выброшены, а перенесены: закладки по каналам переехали в
отчёт вместе с проверкой на 120 строк, отбор ленты по каналу - в проверку
ленты. Каждый переезд назван поимённо.

Проверено: 461 сторож сервера, 2135 сторожей экрана, контроль типов ноль,
статанализ ноль, форматтер чисто, сборка фронта проходит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:18:26 +03:00
Дмитрий 4a993f8575 feat(кошелёк): видно, за что списали и сколько уходит на каждый канал
Три правки по замечаниям владельца от 06.08.2026.

1. Кошелёк переехал из «Рекламных возможностей» в «Финансы», рядом с
   «Биллингом» — это деньги, а не рекламный канал. Перенесён в обоих меню:
   боковая панель и мобильное «Ещё».

2. Каждое списание теперь называет канал, номер и название кампании:
   «Яндекс Аудитория · кампания №21 «Я4 приёмка — впритык…»». Раньше все до
   одного писались немой фразой «Списание за рекламу (факт)». Подпись
   собирается при показе, поэтому заговорили и уже записанные строки —
   летопись задним числом не переписывается, это финансовый документ.

   Попутно закрыта ловушка: номера кампаний у Яндекса и у СМС идут по разным
   счётчикам и совпадают. В боевой летописи есть и «кампания №4» от СМС, и
   рекламные — различить их было нечем. Теперь разводит канал в подписи.
   Поправлен падеж: было «Заморозка снята — кампанию №13».

3. Закладки по каналам с суммой трат прямо на закладке: Всё 157,74,
   Яндекс Аудитория 130,74, Рассылка СМС 27,00. Тратой считается только
   списание — заморозку ещё можно вернуть, что и случилось с 724 руб.

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

Сторожа: 6 на сервере, 5 на экране, каждый сначала увиден красным.
Рекламный блок 441 зелёный, фронт 2108 зелёных, статанализ ноль, типы чистые.
2026-08-06 06:35:55 +03:00
Дмитрий 1f260c0b97 fix/обзвон: честный отчёт о стирании доходит до оператора - З-2.5, второй заход
Приёмка вскрыла, что первый заход воспроизвёл ту же беду на другом основании.

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>
2026-08-05 23:26:49 +03:00
Дмитрий 80987996ed fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное
Три починки, каждая сперва увидена красной.

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

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

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

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

Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
2026-08-05 04:26:27 +03:00
Дмитрий 04657bd6cd feat(воронка): фильтр по нише и на личном экране менеджера
Раньше «Ниша» стояла только на «Воронке отдела». Теперь тот же фильтр есть
и на личном экране менеджера — он обзванивает подряд одну нишу, ему нужнее всех.

Список ниш у менеджера считается по ЕГО карточкам: сужение «только свои»
наложено раньше, поэтому чужая ниша в список не попадает, а ?rubric= не может
стать лазейкой к чужой воронке — на это есть отдельная проверка.

Поведение обоих экранов задаёт один кусок кода, composables/prospectRubricFilter.ts.
Двумя копиями «Без ниши последним» и самосброс исчезнувшей ниши разъехались бы
на первой же правке.

Проверено: Pest по продажам 525 зелёных, Vitest по фронту 2077 зелёных,
vue-tsc и Larastan чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:04:07 +03:00
Дмитрий fcf4331d24 fix(реклама): карточка объясняет, что происходит с объявлениями
Владелец, глядя на две живые карточки: «статус на модерации — не понятно,
идут показы или ожидает! и принято, когда реально отклонили».

Корень: ярлык один на всю кампанию, а объявления внутри в разных состояниях,
и у объявления два независимых признака — прошло проверку и показывается.
Карточка сваливала их в одно слово.

Три починки:

1. Портал спрашивает Яндекс о модерации каждые 15 минут вместо двух часов.
   У кампании #11 проверка кончилась между обходами, и клиент полтора часа
   видел бы «На модерации» на уже работающей кампании. Сторож держит
   расписание */15.

2. Портал считает придержанные объявления — одобренные, показ которых
   Яндекс не включил. Признак уже ставился на объявление, но наверх не
   поднимался; теперь в списке есть banners_held. Два сторожа: слепок
   боевой #6 даёт 13, слепок #11 даёт 0.

3. Ярлык называет положение дел словами, приписка всегда даёт числа:
   «Яндекс проверяет объявления» + «Одобрено 9 из 15, остальные ещё
   проверяются»; «Одобрено, но показ не включён» + «Одобрено 13 из 15,
   отклонено 2. Яндекс их пока не показывает — причина в сообщениях
   кампании»; «Крутится» + «Одобрено 15 из 15». Числа показываются всегда,
   а не только при частичном отказе.

Живой замер, ради которого всё это: у боевой кампании #6 все 15 объявлений
выключены при 13 одобренных — Яндекс просит документы, поэтому показов нет
с 30.07. Карточка про это молчала.

Прогон: 386 сторожей блока рекламы, 1974 фронта, статанализ 0, стиль чист.
Схема не менялась. Тесты гонялись на отдельной базе liderra_testing_s19 —
общую соседняя смена сносила трижды за прогон.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:20:41 +03:00
Дмитрий 4b8c4cc729 feat(воронка): плашка ниши на карточке и фильтр по нише на доске 2026-08-04 13:52:40 +03:00
Дмитрий 656cf80f31 feat(реклама): кнопка «Запустить» у клиента на карточке кампании
Кампания в состоянии «Готова к запуску» не двигалась ничем — ни человеком,
ни расписанием. Клиент собирал рекламу, заливал пятнадцать картинок,
отправлял заявку и упирался в тупик: на карточке только «Изменить» и
«Отчёт». Запуск был возможен лишь обращением, к которому в кабинете нет
кнопки. Поймано приёмкой 03.08.2026 на кампании #11.

Новых дыр кнопка не открывает: путь запуска и так лежал в клиентской группе,
все проверки — деньги, минимум площадки, годность аудитории — внутри самого
запуска.

Три ответа портала показываются по-разному: «готовим картинки» и «аудитория
ещё готовится» — спокойным синим сообщением, это нормальный ход, деньги не
тронуты и в кабинете ничего не создано; смета мала, не хватает денег, Яндекс
молчит — красным. Денежная плашка перечитывается только после удачного
запуска.

Отдельного «вы уверены?» намеренно нет: кнопка подписана, смета показов
стоит тут же на карточке, пауза возвращает заморозку целиком — проверено
живьём 03.08 на кампании #6. Вместо окна под кнопкой строчка «При запуске
1 020,60 руб. будут зарезервированы на кошельке»: клиент знает про деньги
до нажатия, а не узнаёт потом из кошелька.

Сторожа: кнопка есть только у «Готовы к запуску» и нет у работающей и у
черновика; удачный запуск перечитывает список и денежную плашку; «аудитория
ещё готовится» даёт спокойное сообщение и не тревожит плашку; отказ даёт
красное; под кнопкой сказано про резерв сметы.

Прогон: 2049 сторожей фронта зелёные, типы Vue чисты, стиль чист.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:04:27 +03:00
Дмитрий 447ad6af49 fix(рекламный кошелёк): клиент видит, за что заперты его деньги
Приёмка 03.08.2026 на боевом нашла три дыры вокруг одной и той же суммы: у клиента
заморожены 3 333,36 ₽, и узнать за что было негде. Деньги не терялись — терялось
объяснение, а для клиента, у которого заперта треть кошелька, это неотличимо от пропажи.

1) Денежная плашка врала после паузы и возобновления (Я-Ф1/Я-Ф4 FAIL).
   Она грузилась ОДИН раз, при открытии страницы, и о переменах не узнавала. После
   «Возобновить» экран обещал 9 991 ₽ свободных, когда на деле оставалось 6 657,64 ₽:
   клиент считал новую кампанию на несуществующие деньги и получал отказ, которого
   не ждал. Правда появлялась только после перезагрузки.
   Стало: список кампаний сообщает «деньги подвинулись», экран перечитывает плашку.
   Сигнал шлют ровно те три действия, что двигают деньги: запуск, пауза, возобновление.
   Сторож держит не содержимое плашки (оно зелено и при поломке), а сам факт
   повторного обращения за кошельком после денежного действия.

2) Летопись не фиксировала ни постановку заморозки, ни снятие.
   Заморозка писалась суммой 0,00 ₽ — жёстко зашитой, снятие не писалось вовсе.
   Стало: заморозка пишется настоящей суммой со знаком минус (деньги ушли из
   свободных, баланс не двигается), снятие — отдельной строкой типа release.
   Подпись человеческая и с номером: «Заморозили под кампанию №6».

3) На экране кошелька вместо истории стояло «появится позже».
   Стало: живая история движений — новая ручка GET /api/advertising/wallet/transactions
   (последние 100, свежие сверху, чужие не отдаёт) и лента на экране: подпись
   человеческими словами, сумма со знаком, дата по Москве. У заморозки и её снятия
   отдельная пометка «баланс не изменился», чтобы клиент не принял резерв за списание.

4) Карточка кампании называла не ту сумму.
   Показывалось поле budget_rub — число, которое клиент когда-то вписал сам и которое
   в расчёте денег не участвует вообще: «Бюджет показов 200 ₽» при заморозке 3 333,36 ₽.
   Стало: «Смета показов» — считанная сервером (показы × цена за 1000, та же арифметика,
   что и у заморозки), плюс строка «Зарезервировано» с живой бронёй по этой кампании.

Схема не менялась — все поля летописи уже были.

Проверено: 375 сторожей рекламы, 748 по смежным кошелькам (телеграм и СМС ходят тем же
сервисом), 2042 на фронте — все зелёные. Статанализ 0 ошибок, типы Vue чисты, стиль
в изменённых файлах чист. Все четыре сторожа приняты вырезанием: на старом коде красные.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:30:47 +03:00
Дмитрий 4a439ebb80 merge: правка телеграм-смены от 01:59 сведена — на бою снова обе работы
Вторая смена выложила на боевой свою свежую правку в 01:55–02:00, поверх выката
СМС-модуля. Их выкладка заменила сборку экранов целиком, а СМС-модуля в их ветке
нет — в результате экраны клиентских СМС на бою пропали из сборки, хотя код,
маршруты и таблицы остались целы. Их телеграм-правка при этом работала.

Сверка байт в байт показала: расходились ровно 5 файлов, все из их записи
f2ac3f4c7. Остальные 2432 файла кода совпадали.

Сведено, разрешено две склейки:
- phpstan-baseline.neon: взята наша вычищенная сторона, 15 строк заметания не
  возвращены — статанализ после сведения даёт 0 без них;
- vuetify.ts свёлся сам, но обе смены завели одно и то же имя значка
  mdi-file-download-outline. Оставлена одна запись — FileDown вместо простой
  стрелки: их комментарий гласил, что точного значка в Lucide нет, а он есть.
  Запись обслуживает обе кнопки, в комментарии это записано.

Проверено после сведения: экраны 251 файл, 2033 зелёных, 3 пропущено, 0 падений;
PHP по обоим модулям 714 тестов, 714 зелёных; статанализ 0; проверка типов 0.

Выложено на боевой: код и пересобранные экраны. После выкладки сверка байт в байт
всех 2626 файлов кода на бою против этой ветки — ноль расхождений. В сборке
присутствуют и экраны СМС, и экран телеграм-рекламы. Порталы 200, службы активны.

🪤 Урок, который стоил ночи: две смены выкатывают на один сервер из разных деревьев,
и выкладка всегда несёт ВЕСЬ фронт одним куском. Перед любой выкладкой обязательно
сверять слепками, чей код сейчас на бою, и забирать чужое к себе — иначе выкат
молча откатывает соседа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 02:40:42 +03:00
Дмитрий f2ac3f4c7f fix,телеграм: подсказка обещала цену вдвое ниже настоящей + образец файла для базы номеров
Приёмка владельца на боевом, два замечания из трёх (третье — про рекламный
кошелёк и заморозку — отложено, кусок большой).

1. Подсказка «?» у поля медиа обещала «600 ₽ за тысячу с картинкой, 680 ₽ с
   видео». Клиент платит 1008 и 1142,40 ₽. Числа были вбиты в текст руками и
   протухли в ту минуту, когда миграция client_tg_cena_po_media поменяла тариф.

   Лечение в корень, а не подстановкой верных чисел: цену называет тот, кто её
   считает. Ручка оценки отдаёт ceny_za_tysyachu по каждому виду медиа
   (себестоимость × наценка), подсказка собирается из них функцией
   podskazkaProMedia. Пока сервер не ответил — текст без единой цифры: подставить
   «примерные» числа значило бы вернуть ровно эту беду.

   Прайс держим отдельно от охвата: охват на ошибке гасим (устаревший хуже
   никакого), а цены от настроек аудитории не зависят — иначе подсказка мигала бы
   на каждой опечатке в поле.

2. Замечание дословно: «нету скачать файл с примером как надо заполнить для нас
   файл». Кнопка «Скачать образец» рядом с полем загрузки — подпись клиент читает
   уже ПОСЛЕ того, как файл отклонили. Пять строк, написания разные (с плюсом, с
   восьмёркой, со скобками), номера синтетические 7999.

   Заголовка-строки в образце намеренно нет: разборщик нормализует первый столбец
   КАЖДОЙ строки, и слово «Телефон» попало бы в «не похоже на номер» — наш
   собственный образец показал бы клиенту ошибку.

Проверено вырезанием, а не только зелёным:
- вернул в подсказку вбитые 600/680 — покраснели 4 датчика, включая тот, что
  прямо запрещает эти два числа;
- вставил в образец строку-заголовок — покраснели 3.

Полный прогон поймал две мои же поломки, обе настоящие:
- значок mdi-file-download-outline на новой кнопке ОТСУТСТВОВАЛ в карте Lucide —
  на экране стал бы вопросом в кружке. Поймал сторож значков, у которого вчера
  опустошили список поблажек. Добавлен (Download — точного «файла со стрелкой» в
  Lucide нет);
- датчик подсказок ждал PODSKAZKI.media строкой, а её больше нет.

Замеры: экраны 245 файлов / 1870 тестов / 0 падений; телеграм-модуль 327 / 0;
статанализ 0; формат чисто; типы 6 — все в чужих файлах, столько же было до;
сборка 3,80 с. Счётчик в phpstan-baseline сдвинут 13→15 (новые тесты на Pest),
diff проверен глазами: изменилась ровно эта строка.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:59:01 +03:00
Дмитрий b7ad9e0a66 merge: телеграм-реклама второй смены сведена в рабочую ветку перед выкатом СМС
Замер боевого перед выкатом СМС-модуля показал мину: на 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>
2026-08-03 01:16:23 +03:00
Дмитрий 36f5093692 merge: общая ветка с клиентским СМС-модулем сведена в рабочую
Общая ветка переведена вперёд на ветку клиентских СМС — перемоткой, без
слияния, поэтому конфликтов там быть не могло. Затем общая сведена в рабочую
ветку, которая отставала на 79 записей.

Столкновение было одно и знакомое — словарь орфографии cspell-words.txt.
Разрешено правилом «обе стороны настоящие»: слова обеих веток сохранены,
ничего не выброшено. Журнал схемы БД на этот раз свёлся сам, столкновения
номеров не было.

СТОРОЖ ПДн ОСТАНОВИЛ ЗАПИСЬ И БЫЛ ПРАВ. В образце базы номеров, который
клиент скачивает перед своей первой рассылкой, стоял рабочий телефон
владельца — в двух видах. Это не утечка чужих данных: тот же номер публично
опубликован в реквизитах ИП по требованию ЮKassa. Но клиент заполняет этот
файл своими номерами и запускает рассылку — забытая строка означала бы СМС
владельцу за деньги клиента. Заменён на выдуманный из тестового диапазона.

Подсказки на экране рассылок («+7 999 123-45-67») выдуманы изначально;
разрешены в .gitleaks.toml ПО ЗНАЧЕНИЮ, а не по файлу, чтобы сами файлы
остались под охраной. Сторож проверен вырезанием: подложенный номер, не
подпадающий ни под одно разрешение, он поймал; после снятия подложки — чисто.

Служебный счётчик наблюдателя убран в тайник на время сведения и возвращён
после — он машинный и пересоздаётся хуками.

Файлы второй смены, которая работает в этой же папке, не тронуты и в
слияние не попали.

Проверки сведённой ветки: тесты 4533, зелёных 4529, падений нет; экраны
238 файлов и 1906 зелёных, сторож сети не сработал ни разу; статанализ 0;
формат чист.
2026-08-02 23:27:13 +03:00
Дмитрий 00cc072e2f feat,телеграм-реклама: «Моя база номеров» заработала — пункт больше не ведёт в никуда
Это снимает запрет на выкат телеграм-рекламы.

Пункт «Моя база номеров» стоял в выборе аудитории с 27.07, а писать в таблицу
client_tg_contacts не умела НИ ОДНА строка кода: ни экрана загрузки, ни серверной
ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
Клиент выбрал бы «свою базу», увидел пустоту и решил, что портал потерял его
клиентов. Песочница выключена, деньги живые — катить в таком виде было нельзя.

Читающая половина при этом была готова с самого начала: TelegramAudienceService
умеет и нормализацию, и схлопывание дублей, и вычитание стоп-листа. Не хватало
только записи, поэтому работа вышла куда меньше, чем казалось.

Сервер:
- TelegramBazaService — разбор файла, замена базы, очистка. Номер берётся по
  одному в строке либо первым столбцом таблицы, разделители запятая, точка с
  запятой, табуляция. Мусорные строки не роняют разбор, а считаются отдельно.
- три ручки: GET, POST и DELETE /api/telegram/contacts.
- потолок 200 000 номеров и 10 МБ на файл; обрыв по потолку не молчит, а
  возвращается признаком.

Экран, в шаге «Кому показываем»:
- сколько номеров в базе, заливка файла, очистка;
- итог заливки целиком: принято, повторов, не похоже на номер. «Принято 1200»
  без остального читалось бы как «файл зашёл полностью»;
- после заливки счётчик охвата пересчитывается сам.

Решения, которые стоит знать:
- заливка ЗАМЕНЯЕТ базу, а не добавляет. Так предсказуемее: клиент держит базу у
  себя и заливает заново. Подмешивание копило бы номера, от которых он не смог бы
  избавиться — построчного удаления в интерфейсе нет. На экране это написано до
  нажатия, молчаливой потери нет.
- файл без единого годного номера отклоняется, старая база остаётся цела. Иначе
  клиент залил бы файл не того формата и потерял всё.
- замена сделана удалением и вставкой, а не upsert: право UPDATE на таблице не
  выдано, upsert упёрся бы в это на бою и молча правил бы ноль строк.
- наружу отдаём только счётчики. Номера — персональные данные, экрану они не
  нужны и в ответах не появляются.

Приёмка: 12 тестов сервера и 9 тестов экрана, все до кода и все красные по верной
причине — сервер отвечал 405, блока на экране не было.

Замеры: телеграм-модуль 325 тестов 0 падений, экраны 244 файла 1855 тестов
0 падений, статанализ 0, форматтер чисто. Проверка типов 6 ошибок, все чужие,
столько же было до работы.

Для выката, проверить на бою: право USAGE на счётчике client_tg_contacts_id_seq.
Замерил в тестовой базе — счётчик без права, но ровно так же выглядит и счётчик
client_tg_campaigns_id_seq, в который портал на бою пишет. То есть новых прав эта
работа не требует, но проверка дешёвая, а пропущенный grant на бою даёт отказ,
которого на dev не видно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:13:31 +03:00
Дмитрий 3864f6f11a feat,телеграм-реклама: цена показа по виду медиа вместо ступеней по объёму
Мы продавали дешевле, чем покупали. Тариф давал скидку за объём — 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>
2026-08-02 22:15:46 +03:00
Дмитрий 1052c64c6d chore,реклама: мёртвое поле недельного бюджета убрано с фронта
Поле weekly_budget_rub объявлялось в описании ответа Campaign и стояло в 21
заготовке пяти файлов тестов интерфейса, но не читалось нигде — сервер
принимает и правит кампанию по budget_rub.

Поправка к прежней записи: утверждение «сервер его не возвращает» было
неточным. Список кампаний отдаётся узким набором столбцов без него, но
создание и правка возвращают всю строку целиком — поле приходит, всегда
пустое. Мёртвым оно было именно на фронте.

Серверная сторона намеренно не тронута: столбец ad_campaigns.weekly_budget_rub
в базе существует и его заполняют около шестидесяти мест в tests/Feature,
которые пишут в базу напрямую. Там поле настоящее, снос столбца — отдельная
работа с миграцией.

Приёмка: на фронте не осталось ни одного упоминания, проверка типов 0,
линтер 0, полный набор интерфейса 233 файла / 1750 тестов / 3 пропущено /
0 падений. Трём файлам вернул форматирование, которое сбилось от удаления
строк.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:09:35 +03:00
Дмитрий 76cb44b91d feat(телеграм-реклама): пачка 4б — мастер шагов, живой счётчик охвата и серверная ручка оценки
Замечание владельца: «мастер шагов вместо простыни» и «живой счётчик охвата
вместо кнопки Рассчитать». Форма была одна длинная, охват узнавался только по
нажатию кнопки.

Экран:
- мастер из четырёх шагов: Кому показываем → Объявление → Деньги → Проверка;
- живой счётчик охвата на первом шаге, пересчитывается сам при смене источника
  людей, периода или списка номеров (с задержкой, чтобы не дёргать сервер
  на каждую букву);
- кнопки «Рассчитать» больше нет: «Запустить» создаёт кампанию, прикладывает
  картинку и отправляет в кабинет одним нажатием;
- на шаге «Деньги» рядом смета и остаток баланса — нехватка денег видна ДО
  запуска, а не отказом после;
- сводка на последнем шаге повторяет всё выбранное.

Сервер — новая ручка POST /api/telegram/campaigns/estimate:
🔴 Живой счётчик обязан считать на каждое изменение поля, а охват до сих пор
возвращала только POST /campaigns — и она СОЗДАЁТ черновик в базе. Через неё
счётчик наплодил бы десятки кампаний-призраков в списке клиента. Новая ручка
только читает: ни записи, ни денег, ни робота. На это стоит отдельный тест.

Ручка считает ТЕМ ЖЕ кодом, что и создание кампании (общее тело выборки из
сделок вынесено на оба входа TelegramAudienceService). Отдельный тест сверяет
два ответа: разойдись они — клиент видел бы на экране одно число, а платил
по другому.

🔴 Запуск запрещён, когда людей меньше 367 — правило площадки, не наше. Тот же
предохранитель стоит на сервере (AudienceGateTest); на экране он объясняет
заранее, вместо отказа после оплаты.

Новых прав в базе ручка не требует: читает строго то же, что уже читает
создание кампании, и не пишет никуда.

Тесты: +EstimateApiTest (11 на PHP), +telegram-master-shagov (27 на экранах).
14 прежних проверок формы переехали в набор мастера вместе с кодом — сперва
переписаны на новом месте и там проверены, потом убраны со старого. Устарели
по смыслу только две («Рассчитать создаёт черновик», «правка сбрасывает
смету»): считать вручную больше нечего, устареть расчёту негде.

Замер: экраны 240 файлов, 1829 тестов, 0 падений (было 239/1813); телеграм-
модуль на PHP 292 теста, 0 падений (было 281); статанализ 0 (базовая линия
пересобрана — добавлен только новый Pest-файл, удалений нет); типы 6 чужих
ошибок вместо 7; pint чист.

На боевой НЕ выкачено. Глазами не принято — приёмка в пачке 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:04:07 +03:00
Дмитрий 0af5c5696f chore+fix: разметка документации и «неизвестный тип» в интерфейсе убраны в ноль
РАЗМЕТКА ДОКУМЕНТАЦИИ. Проверка сторожа давала 237 замечаний в 21 файле — стало
0 во всех файлах под учётом git. Пустые строки вокруг списков и заголовков
поправлены автоматом в 12 файлах, два файла пришлось делать руками, потому что
автоправка там навредила:
- в описании починки инцидента номера шагов 1…7 — это ИМЕНА, на них ссылается
  сам текст «из шага 2»; автоправка перенумеровала их в 1,1,1,1,2 и сломала
  ссылки. Вывел ровно этот файл из-под одного правила, объяснение — в самом
  файле, остальные правила действуют.
- в протоколе архитектуры автоправка склеила «R-* / DR-*» в «R-*/ DR-*».
  Вместо этого обозначения взяты в обратные кавычки — смысл сохранён.
Семь файлов не тронуты: они вне учёта git — два в папке второй смены и старые
черновики в корне.

ИНТЕРФЕЙС. «Неизвестный тип» убран целиком: было 123 замечания, стало 0.
- Навигация автоподбора описана ОДИН раз — app/resources/js/views/autopodbor/nav.ts.
  Раньше девять экранов повторяли описание с «неизвестным типом», а ещё три
  описывали его каждый по-своему и не полностью.
- Чтение ошибки от сервера собрано в один помощник — app/resources/js/api/errors.ts,
  с проверками. Он намеренно читает ФОРМУ ответа, а не полагается на axios:
  тесты экранов подсовывают ошибку простым объектом той же формы, и экран обязан
  вести себя в тестах так же, как в бою.
- В тестах вместо «неизвестного типа» — именованный доступ exposed<T> и полные
  заготовки: app/tests/Frontend/support/. Каждый тест теперь объявляет ровно те
  поля, до которых дотягивается, и опечатка в имени снова становится ошибкой,
  а не молчаливым undefined. Заодно сняты 14 построчных отключений правила.
- Разведены задвоенные имена в шаблонах: в таблице сделок в одном шаблоне жили
  ДВЕ разные функции isSelected — одна берёт номер сделки, другая строку таблицы.
- Убрана мёртвая функция dirLabel и три неиспользуемые переменные.

ПРОВЕРКА ТИПОВ: было 7 ошибок, стало 5. Две унаследованные закрылись попутно —
более строгие заготовки вскрыли нехватку обязательного поля elements. Оставшиеся
пять были до меня и не в моих строках.

ПРИЁМКА. Разметка проверена вырезанием: подложил поломку обратно — сторож её
поймал, вернул — снова чисто. Полный набор тестов интерфейса: 233 файла,
1750 тестов, 3 пропущено, 0 падений. Рабочий код на PHP не тронут.

NB: LEFTHOOK_EXCLUDE=cspell — тем же приёмом и по той же причине, что и в записи
e0223de4, которой эти шесть файлов исследования заводились в репозиторий: сырой
внешний материал с чужой терминологией. Замерено: до моих правок сторож
орфографии находил в них ровно столько же слов, сколько после — я не добавил
ни одного. Разметка в этих файлах теперь проходит начисто, отключена ТОЛЬКО
орфография и только на эту запись; остальные семнадцать сторожей отработали.
Решение владельца, спрошено отдельно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:07:24 +03:00
Дмитрий a2496dfe61 feat(реклама): честные статусы кампаний — экран больше не зовёт «Крутится» рекламу с нулём показов
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Замечание владельца З-6: «почему статус крутится, когда она отклонена?»
Живой замер кампании #6 на бою: статус running, зелёное «Крутится», показов
доставлено НОЛЬ, потрачено 0 ₽, заморожено 3 333,36 ₽ — третьи сутки.

Главное, что вскрылось: экран физически не мог показать правду. Список кампаний
отдавал estimated_impressions («сколько обещали») и не отдавал delivered_impressions
(«сколько было»), хотя колонка есть. Чисел принятых/отклонённых объявлений тоже не
было. Поэтому чинили с сервера, а не с подписей.

- сервер отдаёт факты: delivered_impressions + счётчики объявлений (всего/принято/
  отклонено), ОДНИМ запросом на весь список — на N+1 поставлен отдельный датчик;
  те же счётчики доезжают и в отчёт по кампании;
- считаются только включённые в показ: снятое галочкой в Яндекс не уезжает и в
  знаменателе «2 из 15» ему не место;
- ярлык по фактам: running при нуле показов → «Принято, показов пока нет»
  нейтральным цветом; пошли показы → зелёное «Крутится»; часть отклонена →
  приписка «часть объявлений отклонена — 2 из 15»; все → красное «Отклонено»;
- правило перехода статусов НЕ тронуто: на rejected висит возврат заморозки
  (AdWalletService::release, «ВЫХОД 2»). Чинили то, что видит человек;
- подписи собраны в один файл (composables/campaignStatusMeta.ts). Их было ДВЕ
  копии — в списке и в отчёте — и они уже разъехались; разъехавшиеся копии и есть
  та разница между экранами, на которую жалуется владелец;
- телеграм-экран: подписи вынесены отдельно и приведены к тем же словам
  («На модерации в МТС» ↔ «На модерации в Яндексе», «Отклонено» на обоих);
  голое «Запущена» → «Запущена в кабинете МТС».

Граница честности: у телеграм-модуля нет счётчика показов ВООБЩЕ — actual_cost_rub
в таблице есть, но её не пишет ни одна строка кода. Поэтому там нельзя сказать ни
«крутится», ни «показов пока нет»: это была бы выдумка того же сорта. Записано
открытым вопросом владельцу в файле замечаний.

Проверено: 435 тестов рекламы Яндекса, 281 телеграма, 1771 тест экранов
(235 файлов, 3 пропущено), статанализ 0, формат чист. Новых тестов 18.
Глазами НЕ принято (приёмка — пачка 5), на боевой НЕ выкачено.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:53:57 +03:00
Дмитрий 7922bffb6d feat(телеграм-реклама): заголовок и картинка в авто-режиме + честная проверка медиа по требованиям МТС
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена
02.08.2026 — каждая дыра ниже стоила живых денег.

1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта;
   утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель
   создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания.
   Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при
   включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране.

2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка
   POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране.

3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08.
   Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF,
   пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало
   в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ
   и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки
   определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe
   из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком
   маленькая: 300x200. Нужна не меньше 640x360».

4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки
   файла, на обоих экранах.

Сверх плана, найдено по дороге:

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

6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой
   «Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом.

Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у
webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe.

Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист.
Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено.

Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не
повторить столкновения 29.07 и 01.08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:41:26 +03:00
Дмитрий b6a15c5bbf merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 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>
2026-08-01 19:50:41 +03:00
Дмитрий 62d81e33f7 Merge remote-tracking branch 'gitea/main' into fix/tg-zagolovok-obyavleniya
# Conflicts:
#	docs/observer/STATUS.md
2026-08-01 16:35:18 +03:00
Дмитрий 9ef688fa8d merge: подтянул main после выката починок модерации Яндекса
Мой коммит уехал в main отдельно — пересадкой на актуальную вершину
и оттуда на боевой. Возвращаю main к себе, чтобы ветка не разъезжалась.

Пришло 9 коммитов, из них главное — работа соседней сессии по воронке
продаж, уже влитая в main и запушенная.

Конфликта два, оба в общих машинных файлах:

- cspell-words.txt — сторону не выбирал, объединил. Все слова обеих
  сторон на месте; убрана ровно одна строка — мой же дубль «админский»,
  который прошлый коммит добавил дважды. Проверено сравнением с версией
  до слияния: другого отличия нет;
- docs/observer/STATUS.md — машинный файл наблюдателя со столбиком часов
  процессов, взята своя версия, его всё равно переписывает хук.

Замечание на будущее: патч «минимум площадки считается по длине периода»
живёт в двух коммитах — 948807507 у меня и в ветке робота, 9df7846fd
в main. В main уехала только вторая копия, main чист. Когда моя ветка
и ветка робота пойдут в main, git встретит этот патч второй раз.

Статанализ остановил слияние, и это была НЕ ложная тревога: файл-эталон
phpstan-baseline.neon склеился из двух версий и перестал соответствовать
коду. Все 16 ошибок — в чужих файлах воронки продаж, пришедших из main
байт в байт, известного ложного класса про Pest. Эталон из main один
не подошёл: всплыли записи, нужные этой ветке. Пересобрал по проектной
процедуре в два шага, phpstan.neon вернул на место. Итог 0 ошибок,
правка эталона мелкая: +43 -31. Моих файлов среди добавленных записей
нет ни одного.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:19:06 +03:00
Дмитрий 947cb3403a fix(воронка-продаж): сроки фильтра по датам — «Просроченные» отдельным пунктом, планы смотрят вперёд
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Просьба владельца 01.08.2026: «убери вчера, наверх создай просроченные,
сегодня, завтра и т.д. и проверь что они правильно привязаны и реально
работают»; во втором режиме «убери завтра — завтра у тебя не может быть».

У каждого режима теперь СВОЙ список сроков, они не пересекаются:

  Что надо сделать (вперёд): Просроченные / Сегодня / Завтра /
                             Ближайшие 7 дней / Ближайшие 30 дней / Произвольный
  Что менялось (назад):      Сегодня / Вчера / 7 дней / 30 дней / Произвольный

Каждый пункт показывает ровно то, что на нём написано. Раньше просроченное
подмешивалось в ЛЮБОЙ выбранный период, и «Сегодня» показывало не только
сегодняшнее — теперь это отдельный первый пункт (period=overdue), а подпись
«плюс всё просроченное» убрана за ненадобностью.

Вторая, невидимая глазом поломка: «7/30 дней» в режиме планов считались
НАЗАД (d7/d30) — «что надо сделать за прошедшую неделю». Добавлены зеркала
next7/next30 в SalesPeriodResolver.

Срок из чужого набора («что менялось завтра») сервер отвергает с 422, а не
подменяет молча текущим месяцем, как делал прежний резолвер по умолчанию.

Проверено:
- сервер: 30/30 (фильтр + резолвер), весь отдел продаж 498/498;
- фронт: 1739/1739 весь набор;
- приёмка вырезанием — подложил поломку в оба места, оба набора покраснели;
- глазами в браузере 1920×1080: списки сроков в обоих режимах, «Просроченные»
  дают ровно забытые карточки, «Завтра» — ровно завтрашнюю (скрины 08–12).

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 975175750a)
2026-08-01 16:16:51 +03:00
Дмитрий 19d9eda368 fix сверка: не кричим про расхождение, пока оператор просто ещё не ответил
Находка приёмки Этапа 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>
2026-08-01 14:22:08 +03:00
Дмитрий 1acbaf9383 Merge remote-tracking branch 'gitea/main' into feat/prospects-manual-testing-kp
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
# Conflicts:
#	docs/observer/STATUS.md
2026-08-01 13:43:02 +03:00
Дмитрий 9ee4e9a79f feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны
- 12-я колонка «Корзина», результат «В корзину» просит только причину;
- в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного
  тестирования»; при нуле дробь не рисуется — «69/0» это шум;
- у «Выслано КП» поле даты подписано «если договорились» и необязательно;
- орган фильтра по датам на ОБОИХ экранах, два режима + период.

Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела»
(снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/).
Браузер поймал то, чего не видели тесты: при смене режима оставался прежний
период, и «что менялось за завтра» давало пустую доску — теперь период
возвращается к «Сегодня», на это заведён отдельный тест.

Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
2026-08-01 13:29:10 +03:00
Дмитрий 7aa54773c1 merge: свёл заголовок объявления с веткой робота — воронка продаж и опрос в одной ветке
Влил fix/robot-yandex-zamok (26 коммитов: сведение с основной, стадии воронки
«Тестирование ручное» и «Выслано КП», чтение вердикта и пересдача по опросу)
в ветку заголовка объявления.

Конфликт был один — docs/observer/STATUS.md, машинный файл наблюдателя
со столбиком часов процессов. Взята своя, более свежая версия; файл всё равно
перезаписывается хуком.

Замер после слияния (своя тестовая база liderra_testing_zag, прогон в тишине):
портал 4069 тестов, 4029 прошло, 16 упало; робот 130/130.
До слияния было 4063/4018/17. Тестов больше, падений меньше — стык чистый.
Падают шесть давних классов, не связанных с этой работой: CreativeRobotEndpoint
(11 — лезет в живой Директ и получает «недействительный ключ»), CreativeJobService,
InAppNotification, PhoneRegionSmoke, ProjectExtensions, SalesOverview.

Проверено отдельно: таблица заданий роботу не ограничивает список режимов
(обычная строка), поэтому новые «read-status» и «resubmit» миграции не требуют.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:48:02 +03:00
Дмитрий 32df332901 fix(телеграм-реклама): заголовок объявления для рекламы сайта — кампания больше не встаёт
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков),
когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал,
«Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже
ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал —
дошла до подтверждения) и 2234490 (сайт с заголовком — дошла).

Портал спрашивает заголовок заранее, на создании черновика: обязателен только для
не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40),
поле на экране появляется по той же развилке. Робот заполняет его в кабинете.

Три ловушки, добытые живыми прогонами (описаны в коде):
- поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать;
- под описание подходит несколько элементов — нужен .first();
- серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту
  давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи.

Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же
13 падающих классов до и после правки (ни одного в телеграм-части). В baseline
статанализа добавлен известный ложный класс Pest для нового файла тестов.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:42:46 +03:00
Дмитрий 22ac6e4f13 feat(смс-клиент): журнал рассылки открывается страницами по 50, а не одним куском
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная
разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч
номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул
из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и
человек стал открывать его чаще.

Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель.
На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер
страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы
возвращаемся туда, откуда ушли.

Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего
запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили
живьём 30 июля на базе номеров.

Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются
по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и
не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице
значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на
сервере, и на экране.

Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать.
Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит
вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом
другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не
доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд
возвращён.

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

Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы.
Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали —
просим первую.
2026-08-01 10:37:50 +03:00
Дмитрий 65f3785bf3 feat(смс-клиент): сверка нашего расчёта с расчётом оператора — в админке у владельца
Строка листа 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) выделено красным. Стенд возвращён.

Статанализ поймал настоящую ошибку: сводные числа читались как поля модели сообщения,
которых у неё нет. Починено — итоги берутся голыми строками, а не моделями.
2026-08-01 01:08:51 +03:00
Дмитрий a57a70268d merge: воронка продаж — стадии «Тестирование ручное» и «Выслано КП» в main
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Конфликты только в документах: журнал «мозга» взят из main, запись v9.28
переставлена поверх боевого журнала схемы. Код не пересекался.

Сторож слоёв проверен отдельно в рабочей папке: 0 нарушений.
Сторож секретов gitleaks отработал по-настоящему.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:28:34 +03:00
Дмитрий c65856ec73 feat(смс-клиент): досыл не дошедшим — отдельной рассылкой «Досыл к рассылке №N»
Строка листа 5.6. В журнале рассылки появилась кнопка «Дослать не дошедшим (N)».
Нажатие спрашивает отдельным окном: сколько человек получат сообщение ещё раз и что
это отдельная рассылка с отдельной оплатой. По подтверждению заводится рассылка
«Досыл к рассылке №N» — у неё свои деньги и свой итог, а первая остаётся целой.

Почему отдельной рассылкой, а не второй попыткой в старой (решение владельца В-200):
переписав старые строки, мы стёрли бы «не доставлено», и человек больше не увидел бы,
что первая попытка не дошла. Да и база второй попытки не даст: на журнале сообщений
уникальный ключ по клиенту, рассылке и номеру.

Досылаем только по ОКОНЧАТЕЛЬНОМУ итогу (моё решение В-216): пока кто-то «ещё в пути»
или пока сама рассылка идёт — кнопки нет, а сервер отказывает человеческими словами.
Правило живёт в одном месте на сервере, экран получает готовый признак и условие не
пересчитывает: разойдись они — человек нажимал бы кнопку и получал отказ.

Деньги. Досыл списывается как обычная отправка (решение В-198) и своей денежной ветки
не имеет — считает, замораживает и списывает тот же общий код заказа. Повторное
нажатие второй рассылки НЕ заводит: ключ заказа постоянный, «resend:{номер}». Второй
клик означал бы вторую оплаченную рассылку тем же людям.

Живой прогон нашёл то, чего не видели тесты (В-227): досыл забывал оператора, которого
портал уже знал. Номер уходил как «вписанный руками», получал пометку «оператора не
знаем», а такую строку обогащение регионом уже не чинит — на бою, где универсального
канала может не быть, досыл не ушёл бы никому при созданной и оплаченной рассылке.
Теперь досыл вспоминает оператора из журнала сообщений первой рассылки: журнал хранится
без срока, в отличие от снимка получателей, который чистится через 90 дней.

Ещё две дыры, которых не было в плане. Первая: «в пути» считается только по
отправленному, поэтому у рассылки в работе он равен нулю — досыл разрешался бы посреди
работы (В-229). Вторая: защита от случайного повтора отбивала бы досыл молча, ведь текст
у него тот же, что у первой рассылки (В-228).

Схема v9.25: колонка resend_of_campaign_id и индекс у client_sms_campaigns. Прав не
требует — это колонка существующей таблицы, а не новая таблица.

Тесты: 8 на сервере, 4 на экране. Все проверены вырезом — двенадцать вырезов, каждый
покрасил именно свои тесты. Живой прогон в браузере: кнопка с числом, окно про отдельную
оплату, рассылка на два нужных номера с сохранённым оператором, первая рассылка цела,
второе нажатие ничего не завело. Стенд возвращён.

Движение денег живьём НЕ проверялось: на стенде включена песочница, а выключить её —
значит слать настоящие СМС за настоящие деньги.
2026-07-31 16:03:35 +03:00
Дмитрий f4ed24e3cc feat(воронка продаж): два новых результата разговора в карточке
Выбор канала + поле значения, подпись поля меняется по выбору. Дата созвона
обязательна у обоих. Платящему клиенту результаты не предлагаются.
«Зарегистрировался» остаётся доступен с новых стадий — иначе из них не выйти.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:50:53 +03:00
Дмитрий 2d865d3fe5 feat(смс-клиент): в журнале рассылки видна судьба каждого номера и честное объяснение про трое суток
Строки листа 5.1, 5.3 и 5.4. До этого человек видел только «отправлено» — то есть что
мы отдали сообщение оператору. Дошло ли оно до людей, экран не говорил вовсе, хотя
портал это уже знал: судьбу собирает Task 3, а итог считает Task 5.

Оказалось, до экрана эта работа не доезжала: открытие журнала рассылки БРАЛО из ответа
сервера только сообщения, а присланный итог по судьбам молча выбрасывало (В-225). Теперь
не выбрасывает.

Что видит человек: колонку «Судьба» рядом со «Статусом», над таблицей итог
«Доставлено · Не доставлено · Ещё в пути», а под ним — объяснение, что оператор досылает
до трёх суток. Объяснение показывается только пока кто-то правда в пути; когда итог стал
окончательным, оно исчезает само.

Словарь судеб — ОТДЕЛЬНЫЙ от словаря статусов отправки (В-70). Тот отвечает на вопрос
«ушло ли от нас», этот — «дошло ли до человека». Смешать их значило бы написать на экране
«отправлено, не доставлено» одним словом. Числа экран не складывает сам — берёт готовые
с сервера: второй счётчик однажды разошёлся бы с первым, а речь про деньги.

Тесты: 4 новых, каждый проверен вырезом — шесть вырезов, шесть красных, и каждый покрасил
именно свой тест. Проверка идёт ПОСТРОЧНО: «не доставлено» содержит внутри себя
«доставлено», и проверка по всей странице зеленела бы, даже если экран напишет всем
номерам одно и то же.

Живой прогон в браузере парный: пока двое в пути — итог 1/1/2/1 и объяснение видно;
довёл судьбы до окончательных — итог 3/1/0/1, объяснение исчезло. Стенд возвращён.

Отдельная запись про приборы (В-224): скрипт вырезов показал 47 красных из 85 на чистом
коде. Виновата одна буква — папка задавалась как «c:» вместо «C:», и vite грузил модули
дважды, отчего подмены в тестах не срабатывали. Поймал это базовый прогон без вырезов —
тот самый, что появился после прошлого вранья прибора.
2026-07-31 10:05:13 +03:00
Дмитрий 97a93cb763 feat(смс-клиент): кнопка «Продолжить рассылку» — доводим до конца тех, на кого не хватило денег
Строка листа 4.13, решение владельца В-49 «надо обязательно». Рассылка встала
из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт дальше
с того места, где остановилась.

🔴 Главная мина (В-151, подтверждена чтением кода): джоб отправки считает
обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, без
разбора статуса. А номер, на котором кончились деньги, записан как
«не хватило денег». Значит простой повторный запуск молча пропустил бы его:
человек заплатил бы за продолжение, а получатель не получил бы ничего.

🔴 И вторая находка, которой в плане не было (В-184): «оставить строку и просто
не считать номер обработанным» НЕВОЗМОЖНО физически — на журнале сообщений
уникальный ключ по (клиент, рассылка, номер), вторая запись не вставится.
Поэтому продолжение эти строки УДАЛЯЕТ, и по смыслу это верно: «не отправили
из-за денег» фиксирует НЕслучившееся и после пополнения перестаёт быть правдой.
Заодно замерен масштаб: при остановке пишется РОВНО ОДНА такая строка, у
остальных номеров записей нет вовсе.

Что сделано:
· POST /api/sms/campaigns/{id}/resume — убирает строки «не хватило денег»,
  ставит рассылку в очередь, гасит причину остановки (иначе экран продолжал бы
  объяснять человеку прошлую остановку у работающей рассылки);
· продолжаем ТОЛЬКО остановленную ИЗ-ЗА ДЕНЕГ (В-187). Остановленную человеком
  продолжать — значит отменить его решение; сорванную сторожем — наступить на ту
  же поломку. У каждой причины свой человеческий отказ, и текст под тестом;
· правило живёт в ОДНОМ месте на сервере (как enableDecision в Task 4): список
  рассылок отдаёт готовый признак can_resume и число «сколько ещё уйдёт». Счёт
  остатка — в читателе снимка, где уже живёт «кто ждёт» (одно место, В-96);
· кнопка «Продолжить» в таблице рассылок + подтверждение: сколько осталось
  отправить, что повторно никому не пойдёт, что цена прежняя;
· цену НЕ пересчитываем, снимок НЕ пересобираем, заново НЕ замораживаем
  (В-39, В-188): списание идёт поштучно с проверкой перед каждым номером, в
  минус рассылка не уйдёт — кончатся деньги, встанет снова с той же причиной.

🔴 Мой же тест поймал расхождение с моим же решением (В-189): проверку денег я
впихнул в правило «можно ли продолжить» — и кнопка исчезла бы именно у того,
кому она нужна. Разделил: состояние — признак для экрана, деньги — проверка по
нажатию с числами («свободно 0.00 ₽, одно сообщение 8.50 ₽»).

Право DELETE на журнал сообщений рабочей роли — миграция 2026_08_01_101000,
схема v9.22 (В-186, вторая половина мины В-152): без него продолжение на бою
падало бы «нет доступа». Сторож MigrationGrantsTest расширен.

Проверено: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов
17/17, фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan 2
чужие давние, vue-tsc 5 чужих давних, pint чисто. Шесть вырезов, каждый
покраснел ровно там, где вырезан.

🔴 Живой прогон ДЕНЕЖНЫЙ и парный под боевой ролью crm_app_user: рассылка на 5
номеров при деньгах на 2 встала (ушло 2, списано 17.00 ₽, одна строка «не
хватило денег»); продолжение без пополнения — отказ с числами; БЕЗ пометки
клиента рассылка не видна вовсе (изоляция держит); после пополнения с пометкой —
ушло 5 из 5, списано 42.50 ₽, в журнале ровно 5 записей по одной на номер, «не
хватило денег» не осталось. В браузере: кнопка видна глазами, после нажатия и
прогона очереди рассылка «завершена, 5 из 5», кнопка исчезла, у нетронутой
рассылки осталась. Заодно поправлена речь: было «отправим ещё 3 сообщений».

Стенд возвращён и сверен; намеренное изменение одно — миграция накатана на
локальную dev-базу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:24:35 +03:00
Дмитрий 4784382d49 feat(смс-клиент): журнал автоматических СМС за 30 дней на экране клиента
Строка листа 4.11, решение владельца В-102 «обязательно нужен». Причины по
авто-СМС писались в базу, но человеку их не показывал НИ ОДИН адрес портала:
у авто-СМС нет кампании, а единственный список сообщений отбирает их по
номеру кампании. Это и была суть задачи, а не мелочь на потом.

Что сделано:
· GET /api/sms/auto-log отбирает ровно авто-СМС (кампания пуста) за 30 дней:
  «ушло», «не ушло» и разбивка по причинам — АГРЕГАТАМИ по всему окну, из
  базы приезжают числа, а не тысячи строк;
· поимённый список — последние 100 событий, потолок жёсткий, параметра у
  клиента нет вовсе; если событий больше, экран честно говорит «Показаны
  последние 100 событий из N. Числа выше — за все 30 дней» (В-170, класс
  В-166: обязательство держится запретом на сервере, а не вежливостью экрана);
· блок на вкладке «Авто-СМС»: числа, причины человеческими словами по уже
  заведённому словарю подписей, таблица событий, а на пустом журнале —
  фраза «За 30 дней автоматических СМС не было» вместо пустой таблицы.

Попутно починены две неправды экрана — обе внутри обязательства «человеческими
словами», а не сверх него:
· подпись «отписались раньше» переписана на «номер в вашем стоп-листе»
  (В-169): возможности отказаться у получателя НЕТ вовсе (решение В-30),
  номер в этот список вносит сам клиент. Подпись пережила отмену целой
  функции и объясняла человеку то, чего в портале нет — класс В-154;
· пробное сообщение считается НЕ ушедшим (В-172). План велел обратное, но
  денежный счётчик модуля считает только настоящее «отправлено», и подпись
  экрана говорит «пробный режим — не отправлено». Это четырнадцатая ошибка
  плана, найденная тем же приёмом: открыть код и посмотреть, как это же
  состояние трактуют соседи.

Статанализ поймал то, чего не увидели ни четыре зелёных теста, ни живой
прогон, ни браузер (В-174): разбивка читала у модели поля, которых у неё нет.
Переписано на пару «причина → сколько». У каждого прибора своя слепая зона.

Проверено: ClientSms 309/309, приём лидов 17/17, фронт 1697 + 3 пропущенных,
phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Восемь вырезов,
каждый покраснел там, где вырезан. Живой прогон под боевой ролью crm_app_user,
парно: события писал НАСТОЯЩИЙ джоб авто-СМС — с пометкой клиента «ушло 1
(списано 9.00 ₽), не ушло 2: пробный режим 1, стоп-лист 1», без пометки тот же
вызов дал ноль. На 363 событиях — ровно 100 строк в таблице при полных числах.
ДаДата не тронута: клиент подменён заглушкой. Стенд возвращён и сверен.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 06:45:25 +03:00
Дмитрий 284163de5a feat(смс-клиент): своя база листается страницами, а не грузится целиком
Строка листа 4.7. База клиента бывает на десятки тысяч номеров, и экран
получал её одним куском: замер на 20 000 номерах — 5.09 с и 3.60 МБ в одном
ответе. Стало 0.04 с и 0.01 МБ на страницу.

Что сделано:
· GET /api/sms/contacts отдаёт страницу по 50 номеров и поля страницы
  (total / page / per_page / last_page) в том же объекте, где уже жил потолок
  загрузки — форму ответа меняли ОДИН раз, в прошлой задаче (В-160);
· больше 200 номеров за один ответ не отдаётся НИКОМУ (В-166): иначе
  постраничность обходится параметром ?per_page=100000 и обязательство
  «не грузится целиком» остаётся невыполненным;
· экран показывает «Всего номеров в базе: N» и листалку, страницу берёт у
  сервера, а не режет список у себя.

Живой прогон под боевой ролью нашёл то, чего не видел ни один из шести
зелёных тестов (В-167): вторая страница отдавала ТЕ ЖЕ номера, что первая —
paginate() брал номер страницы из глобального запроса приложения, а не из
переданного в метод. По HTTP это одно и то же, поэтому тесты через getJson
врали хором. Номер страницы читается явно; прибор — тест, зовущий метод так
же, как живой прогон.

Двух мест план не касался вовсе, оба про враньё экрана:
· удаление номера убирало строку только на экране — при страницах осталось бы
  49 из 50, «всего» не менялось бы, а номер со следующей страницы не
  подтягивался (В-164); теперь страница перечитывается, а опустевшая
  последняя отступает на шаг назад;
· после загрузки человек оставался на прежней странице и не видел ни одного
  своего нового номера при надписи «Добавлено: N» (В-165) — теперь возвращаем
  на первую страницу, где эти номера и лежат.

Проверено: ClientSms 305/305, приём лидов 17/17, фронт 1694 + 3 пропущенных,
phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Восемь вырезов,
каждый покраснел там, где вырезан. В браузере: 50 строк, «Всего номеров в
базе: 20 000», листалка 1…400, переход на вторую страницу уходит запросом
?page=2 (ответ 9.6 КБ). Стенд возвращён и сверен со снимком до работы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:38:06 +03:00
Дмитрий 1d292082ab feat(смс-клиент): потолок загрузки базы и пачечная запись вместо построчной
Строка листа 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>
2026-07-29 17:30:03 +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
Дмитрий 1122c4ddc9 feat(смс-клиент): номер, вписанный руками, принимается в любом виде
Строка листа 4.8. Портал теперь понимает «+79991234567», «8 999 000 00 02»,
«(999) 000-00-03» и «7999-000-00-04» — раньше первый же такой номер отбивался
ошибкой 422.

Вся строка оказалась одной лишней проверкой в правилах заказа: 'phones.*' =>
'string|size:11' требовала ровно 11 знаков и срабатывала ДО разборщика номеров,
который читает все эти виды прекрасно. Умение было — ему мешала проверка.

Разбор номеров живёт в одном доме (normalizeManualPhones), куда ходят и
предпросмотр, и заказ: второй такой дом означал бы, что смета и факт считаются
по разным спискам.

Непонятые номера не пропадают молча (строка листа так и требует —
«возвращаются образцами»): сервер отдаёт их число и до двадцати образцов, а
экран говорит «Не поняли 1 номер: абвгд — проверьте их и впишите заново.
Остальные уйдут как обычно». Отдаём и в предпросмотре, и в ответе на заказ:
между ними список могли дописать.

Доказательства: 3 серверных теста и 2 фронтовых (второй парный — когда всё
разобрано, экран молчит), 2 выреза, живой прогон в браузере.
ClientSms 284/284, приём лидов 17/17, фронт 1687, phpstan 2 чужие давние,
vue-tsc 5 чужих давних, pint чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:59:39 +03:00
Дмитрий 65e270583c feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 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>
2026-07-29 15:25:37 +03:00
Дмитрий 6d52f390a0 feat(смс-клиент): кнопка «включить имя обратно» — списывает и включает сразу
Строка листа 4.5, Этап 4 Task 4.

Раньше имя, которое клиент выключил сам или которое отключили за долг, можно
было вернуть только одним способом: заказать заново, снова приложив документы и
снова дождавшись согласования у МТС. Теперь у клиента есть кнопка: она берёт
плату за месяц вперёд и включает имя сразу — решение владельца. Денег не хватает
— портал говорит, СКОЛЬКО именно не хватает, и не трогает ни имя, ни кошелёк.
Документы заново не запрашиваются: заявка уже согласована, сканы на месте.

Ключ списания у кнопки тот же, что у ночного работника, поэтому за один
календарный месяц имя оплачивается один раз — кто бы его ни включал. Сумма
нигде не зашита, берётся из карточки имени и вырастет сама, когда подключим
остальных операторов.

Плана оказалось мало ЧЕТЫРЕЖДЫ, и все четыре раза — про деньги.

В-143: план не смотрел, КТО отключил имя. Имя отключает и владелец руками, и оно
у МТС погашено; клиент нажал бы кнопку, портал взял бы с него плату, а имя не
работало бы. Это ровно то, что работнику запретили в прошлой задаче. Кнопка
теперь смотрит в ту же графу «почему отключено» и при включении её гасит.

В-144: слово «отменено» стоит и у имени, которое когда-то работало, и у заявки,
которую клиент отозвал ещё на согласовании. Вторую план включил бы как рабочее
имя и списал за неё деньги — за имя, которого у оператора нет. Отличаю по
отметке согласования: её ставит ровно одно место, кнопка одобрения у владельца.

В-145: имя, оплаченное вперёд, план списал бы второй раз — а при пустом кошельке
ещё и отказался бы включить имя, за которое уже заплачено. Смотрю на «оплачено
до»: срок в будущем — включаю без списания и срок не двигаю.

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

Отдельно В-148 — его поймал только браузер, мимо прошли 12 серверных и 66
фронтовых тестов. Экран писал «плата за текущий срок уже внесена» у имени,
отключённого за долг с апреля. Ноль в сумме приходил не от оплаты, а от
песочницы — одно значение с двумя разными причинами. Развёл: сумма считается
только по сроку оплаты, песочница гасит сам денежный шаг.

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

Живой прогон под боевой ролью crm_app_user, семь нажатий: 50 ₽ при плате 100 —
отказ «Не хватает 50.00 ₽», не тронуто ничего; пополнил до 550 — имя работает,
списано ровно 100 ₽, оплачено до 29.08; имя владельца при 5000 ₽ — отказ, деньги
целы; неодобренная заявка при 5000 ₽ — отказ, деньги целы; второе нажатие —
«имя уже работает», в движениях одно списание. Пара на пометку клиента: без неё
то же нажатие не делает ничего, с ней — списывает и включает.

Живой прогон в браузере: у имени с долгом видна кнопка и строка «Спишется
2500.00 ₽ — плата за имя за месяц»; нажал — карточка стала «Ваше имя: MYSHOP
(активно)». У имени, отключённого владельцем, кнопки нет вовсе.

Прогоны: клиентские СМС 275/275 (11 пачек, все с первой попытки), приём лидов
17/17, фронт 1683 + 3 пропущенных, phpstan ровно 2 чужие давние, pint чисто.
Проверка типов фронта: 5 ошибок в 5 файлах вместо прежних 8 в 6 — задал явный
тип ответа в спеке, который и так правил, и три давние ошибки ушли вместе с
моими. Схема базы не менялась, миграций нет.
2026-07-29 12:49:47 +03:00
Дмитрий ba8e936848 feat(смс-клиент): владелец сам задаёт, через сколько считать рассылку зависшей
Строка листа 4.3, Этап 4 Task 2.

Срок, после которого сторож считает рассылку зависшей, переехал из кода в
админку «СМС» — рядом с границами окна отправки. Поле подписано человеческими
словами и объясняет последствие: портал сперва попробует рассылку дожать, потом
остановит и вернёт клиенту замороженные деньги, а ту, что честно ждёт утра
получателей, не тронет.

Границы 5…1440 минут — не вкусовщина. Ниже пяти сторож срывал бы рассылки,
которые просто идут медленно: проверка средств и запись в журнал на каждый номер
занимают время. Выше суток чужие деньги висели бы замороженными дольше, чем
клиент вообще помнит про эту рассылку. Отказы написаны словами, а не кодами.

Поле НЕобязательное — как и границы окна: этот адрес зовут и те, кому нужны
только настройки имени отправителя, и ломать им запросы права не имеем.
(План велел сделать поле обязательным — это была ошибка плана, журнал В-139.)

Проверено вырезанием, два выреза, оба вернуты:
— убрал границы на сервере → покраснели оба теста про границы;
— убрал отправку срока с экрана → покраснели три фронтовых, включая новый.

Живой прогон в браузере: выставил 120 → сохранилось → пережило перезагрузку
страницы; ввёл 2 минуты → отказ «Слишком мало: рассылка может идти медленно,
и сторож срывал бы живые. Ставьте хотя бы 5 минут», в базу не легло. Вернул 60.

Прогоны: затронутые тесты 32/32, фронт целиком 1678 + 3 намеренно пропущенных
(было 1676, мои +2; одна ошибка — та же чужая давняя), vue-tsc ровно 8 чужих
давних в 6 файлах, pint чисто.
2026-07-29 10:25:57 +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
Дмитрий 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
Дмитрий 5fd18c35b1 feat(смс-клиент): экран объясняет цену и говорит, во сколько рассылка начнётся
Этап 3 «Время и цена», Task 7. Строки листа 3.11, 3.12, 3.13, 3.15.

Клиент видел одну цифру — цену за штуку — и не понимал, откуда она взялась и почему
завтра будет другой. Теперь смета проговаривает всё вслух: сколько СМС ушло в этом
месяце, какая ступень была бы без этого заказа, какая выходит с ним, и что 1-го числа
счётчик обнулится. У первой рассылки месяца формулировка другая: сказать новому клиенту
«вы были на 9.00 ₽» — неправда, он ни на чём не был (В-115).

Отдельно — предупреждение до нажатия кнопки (решение владельца В-86): «Сейчас 21:30 по
Москве — рассылка начнётся завтра в 01:00 по Москве». Оба часа приходят С СЕРВЕРА: на
компьютере клиента может стоять что угодно, а решение об окне принимает сервер. И всегда
сказано, ЧЬЁ это время (В-117): окно 10–20 у получателя местное, и камчатское утро —
это час ночи в Москве. Берётся самое раннее утро среди получателей.

Про тех, у кого регион ещё не выяснен, экран говорит отдельно: момента начала у них нет
вовсе, мы не знаем, когда узнаем регион (В-116). Промолчать было нельзя — человек ждал бы
отправки, которой сегодня не будет.

Правило часов осталось в одном доме (SmsQuietHours::earliestOpening), формула цены — в
своём (ClientSmsPricing::explainFor, счётчик месяца спрашивается один раз на все три
числа). Экран по-прежнему ничего не считает сам — ни денег, ни часов.

🔴 Две неправды, которых не видел ни один тест.
· Самопроверка diff'а: цена за СМС стояла на экране дважды (В-119). Разойтись эти цифры не
  могли, но человек читает две одинаковые цены рядом как две разные. Оставил одну — в
  смете, там, где рядом объяснено, откуда она взялась.
· Живой прогон с ДЛИННЫМ текстом вскрыл давнюю, с Этапа 1, нестыковку (В-120): смета
  писала «Уйдёт 43 СМС — 1462.00 ₽», хотя 43 — это НОМЕРА, а СМС выходило 172. Цифра и
  сумма в одной строке не сходились между собой, и ни один тест этого не ловил: все брали
  короткий текст, где числа случайно совпадают. Теперь «Уйдёт на 43 номера — это 172 СМС,
  1462.00 ₽», число СМС считает сервер. Починено и в окне подтверждения.

Прогоны: СМС 239/239 (пачками по 3–4 файла, В-112), приём лидов 17/17, фронт 1674 зелёных,
phpstan 0 своих, pint чисто, проверка типов — 8 чужих давних.

Вырезами проверено пять раз: убрал ступень «без заказа» из ответа — покраснел тест 3.12;
убрал «окно открыто — значит сразу» — покраснел контрольный «днём начала нет»; взял первое
утро вместо самого раннего — покраснел камчатский тест; сделал фразу всегда «вы были» —
покраснел тест первой рассылки месяца; показал сноску всегда — покраснел контрольный.

Живьём (21:30 МСК, песочница, локальная база): три номера — Москва, Камчатка и один без
региона, 99 отправленных СМС в журнале. Экран показал разом все шесть строк. Парные
проверки: расширил окно до 23:00 — фраза про час исчезла; обнулил накопленное — цена
вернулась к 9.00, фразы про удешевление и сноски не стало; заказ на 172 СМС без
накопленного — вторая формулировка про удешевление. Стенд возвращён: окно 10–20,
0 контактов, 0 сообщений, 0 рассылок, 0 снимков.

🪤 Урок про стенд: тестовую базу чинить ТОЛЬКО `migrate:fresh` с чистого листа. `db:wipe
--drop-types` + ручной `migrate` оставляют её полумёртвой — чужая миграция чата ломается
на недочищенной базе, и дальше все прогоны врут «таблицы не существует».
2026-07-29 06:19:16 +03:00