Commit Graph

244 Commits

Author SHA1 Message Date
Дмитрий b65ad2ecf8 merge: рабочая ветка сведена в главную — вкладка «Активность» отвечает «жив ли клиент», плюс обзвон, телеграм и запрет переадресации
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
# Conflicts:
#	docs/observer/STATUS.md
#	docs/superpowers/runbooks/2026-08-06-rezultaty-priyomki-telegram.md
2026-08-07 11:31:42 +03:00
Дмитрий 654781102f feat: типы и маппер под блок «жив ли клиент» и ленту дел 2026-08-07 08:54:44 +03:00
Дмитрий bed1389b01 merge: рабочая ветка сведена в главную — починка возобновления рекламы и сторожа очередей
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Столкновений нет. Внутри: Т-Б3 (кампания оживает после пополнения),
incidents:watch-failures переживает нулевой байт, статус обзвона в карточке сделки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:29:16 +03:00
Дмитрий bde377a111 feat обзвон: статус обзвона виден в карточке сделки, пометки касаний в списке
Повторная запись той же работы. Первую соседняя смена сняла с ветки откатом
на один шаг назад, сделанным дважды подряд: первый откат убрал их собственный
коммит про кошелёк, второй убрал мой. Содержимое сверено побайтно со снятым
коммитом 015a10070 — все семнадцать файлов совпали до знака.

Менеджер открывал карточку клиента и не знал, звонили ему уже или нет, чем
кончился разговор, есть ли запись. Он набирал человека, которому робот звонил
час назад и которому уже пообещали перезвонить завтра, — человек слышал от
одной компании два несогласованных звонка. Теперь в карточке есть блок
«Обзвон», а в строке списка сделок — пометки о том, что мы этого человека уже
трогали.

Главное в задаче — не показ, а замок. Восемь исходов обзвона живут В ОДНОМ
столбце obzvon_number_results.outcome, и «не дозвонились» там такое же
разрешённое значение, как «переведено». Замок в базе сторожит СПИСОК значений,
а не их смысл, поэтому требование «двенадцать недозвонов после удачного
разговора не затирают удачный разговор» базой не охранялось вовсе: правильное
на вид действие с разрешённым значением давало запрещённый смысл, и ошибка
выглядела бы безупречно. Заведена единственная дверь записи исхода, и правило
спора вынесено в одно место — настройку.

Что появилось:

- config/obzvon.php — одно место раскладки восьми исходов на «про номер» и
  «про попытку», плюс два временных ответа на вопросы, которые владелец ещё не
  закрыл: где кончается «хоть секунда разговора» и сколько прошлых списков
  показывать в карточке. Переиграть каждый — поправить одно число;
- RaskladkaIskhodov — единственный, кто знает разницу между недозвоном и
  семью остальными исходами. Три правила: недозвон не ложится поверх
  разговора никогда; разговор ложится поверх недозвона всегда; два разговора
  спорят временем, и опоздавший отчёт не перепишет свежий;
- ItogPoNomeru — единственная дверь записи, с замком строки против гонки двух
  отчётов робота по одному номеру. В боевом коде её сегодня не зовёт никто:
  робот ещё не звонит. Дверь поставлена ДО первого пишущего — в этом её смысл;
- SvodkaObzvonaVSdelke — читающая сторона. В карточку: одно верхнее значение,
  лента состоявшихся разговоров по каждому номеру, недозвоны свёрнуты в
  счётчик. В список сделок: пометки касаний одним запросом на модуль на всю
  страницу, а не запросом на строку.

Слова для пометок подобраны нарочно другие. На экране сделок «Звонки» и «СМС»
уже означают, ОТКУДА пришёл лид. Назвать пометку теми же словами значило бы
дать человеку прочесть её задом наперёд, поэтому в колонке «Касания» стоят
«Обзвон» и «Рассылка».

Модулей два, а не четыре. Столбца deal_id нет ни у одной таблицы телеграма и
нет у рекламы — связи со сделкой у них не существует, и придумывать её было бы
враньём. Устройство под третий модуль открыто, и сторож числом запросов
обязан его заметить.

Схема базы не тронута: всё нужное было построено раньше.

Сторожа показаны красными шестью вырезаниями: вырезанное правило Т84 роняет
два сторожа сразу, «недозвон считается разговором» даёт девять строк ленты
вместо двух, запрос на строку даёт 61 запрос вместо 16, слияние номеров роняет
четыре сторожа, подмена слов пометок ловится сразу. Возврат каждого доказан
слепком со снятием невидимых знаков конца строки.

Прогоны: полный 5130 проверок, 5126 зелёных, 16099 утверждений, красных ноль.
Обзвон 234 из 234. Статанализ ноль, deptrac ноль.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:23:37 +03:00
Дмитрий 6af73653b6 merge: рабочая ветка сведена в главную — телеграм, обзвон, СМС и лендинг
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Сведено 265 коммитов ветки feat/prospects-manual-testing-kp (c84fed271)
в главную. Столкновение было одно — машинный файл docs/observer/STATUS.md,
взята свежая версия ветки.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 19:21:59 +03:00
Дмитрий 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
Дмитрий 7bd9c67053 feat(смс): экран показывает, что будет с рассылкой в КАЖДОЙ сети
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Одно имя «на всех» стало полуправдой 05.08.2026: номера с несогласованным именем
перестали уходить вовсе, а к Билайну и МегаФону канала пока нет. Экран продолжал
обещать отправку — половина базы исчезала бы для клиента молча.

Теперь на экране «Имя отправителя» разложено по четырём сетям владельца:

  ✓ МТС      уйдёт под именем «mybrand.ru»
  ✗ Билайн   не уйдёт — сюда мы пока не шлём
  ✗ МегаФон  не уйдёт — сюда мы пока не шлём
  ✗ Теле2    не уйдёт — ваше имя у этого оператора не согласовано

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

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

Названия операторов словами — на ЭКРАНЕ, не на сервере: сервер отдаёт ключ и
причину, как назвать это человеку — дело интерфейса (так же в соседних экранах).

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:20:58 +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
Дмитрий 38feb85d65 merge: подтянул общую главную с сервера — 16 записей по рекламе в Телеграме
Наша главная и серверная разошлись 02.08. Свелось само везде, кроме
docs/observer/STATUS.md — он авто-генерируемый, расхождение только в отметке
времени и списке процессов; взят наш, он свежее.

Правки соседей не тронуты: routes/web.php и остальные файлы сведены построчно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:20:06 +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