Commit Graph

68 Commits

Author SHA1 Message Date
Дмитрий 4f491ac109 merge: имя отправителя выбирается по оператору получателя
# Conflicts:
#	docs/observer/STATUS.md
2026-08-05 07:38:03 +03:00
Дмитрий 593030271b feat(смс): имя отправителя выбирается по оператору получателя, а не одно на всех
Имя согласовывает ОПЕРАТОР, и у каждого оно своё. Клиентская рассылка несла одно
имя всем каналам, а умолчанием ему служило `services.sms.mts.naming` — то есть имя,
согласованное с МТС, подставлялось Теле2 и подставилось бы Мегафону и Билайну.
Живой отказ Теле2 `invalid_source_address` 04.08.2026 — ровно этот механизм. На бою
у клиентов заведено НОЛЬ своих имён, значит это касалось каждой отправки.

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

Переведены на него оба места отправки: рассылка (SendClientSmsCampaignJob) и
авто-СМС по сделке (SendAutoSmsForDealJob).

Универсальный СМС-центр передавал имя КАК ЕСТЬ — теперь тоже падает на своё
(SMSC_NAMING). Раньше пустое имя туда не приходило никогда, с этой правкой стало
бы приходить: ушли бы СМС без отправителя.

Заплатка внутри канала Теле2 (приведение написания к своему регистру) остаётся, но
больше не несущая: она чинила следствие в одном канале, а причина была общая.

Экранное имя (ResolvesClientSmsSenderName) НЕ трогали — это запись намерения, а не
то, чем подпишется сеть; в его шапке теперь написано почему, чтобы не «починили»
обратно.

🪤 Урок по дороге: авто-СМС ловит любую ошибку и молча выходит. Справочник имён был
передан не в ту функцию — вместо падения получилась ТИШИНА, не уходило ничего.
Поймали три чужих теста, до того зелёных.

Проверено: 4614 тестов, упал один чужой (ExampleTest — в этой папке не собран
фронтенд). Статанализ 0. Стиль правленых файлов чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 05:28:35 +03:00
Дмитрий 95ce07b591 fix(смс): новая запись про адрес звонившего писалась уровнем, который бой выбрасывает
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
Приёмка на бою вскрыла, что хвост был закрыт только на вид: строку писали
уровнем «к сведению», а на боевом LOG_LEVEL=warning — всё, что ниже, молча
отбрасывается. Запись не появилась бы в журнале НИКОГДА, и круг «список нельзя
заполнить, потому что адреса неоткуда взять» остался бы замкнут — только теперь
незаметно, с виду починенный.

Замерено живым вызовом приёмника на бою 04.08: соседнее предупреждение про
незнакомое сообщение в журнал легло, а строка «к сведению» — нет.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:13:41 +03:00
Дмитрий 9b634d20dc fix(сторож секретов): приёмочные документы в корне docs/superpowers/ вставали насмерть у всех
Полная проверка истории находила 5 «незамаскированных телефонов» в
docs/superpowers/2026-07-27-PRIEMKA-client-sms-fixes.md и не пускала push НИКОГО.

Замерено: все пять с префиксом 7999 — синтетические по правилу проекта
(«реальные НИКОГДА»). Настоящих персональных данных нет.

Причина: список исключений покрывает подпапки docs/superpowers/{plans,runbooks,
specs,audits,findings,prototypes}, а этот документ лежит ПРЯМО в docs/superpowers/
и под них не подпадал. Файл из истории не убрать, не переписав общую ветку, —
значит, чинить надо список.

Разрешение узкое: только docs/superpowers/ГГГГ-ММ-ДД-PRIEMKA-*.md. Прочие файлы
корня docs/superpowers/ проверяются по-прежнему.

Проверено: до правки — 5 находок, после — «no leaks found» на 7589 записях.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:00:14 +03:00
Дмитрий 1987737681 fix(смс): приёмник Теле2 сам называет адрес звонившего — белый список было нечем заполнить
Пока SMS_T2_WEBHOOK_IPS пуст, приёмник никому не отказывает — а значит, и записи
«звонили с такого-то адреса» в журнале не появляется. Круг замкнут: список нельзя
заполнить, потому что адреса неоткуда взять, а взять неоткуда, потому что список пуст.

Теперь при пустом списке каждый принятый звонок пишет строку
client_sms.delivery_hook_open_gate с адресом. Как только список задан — смолкает,
шуметь в журнале постоянно не будет.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:14:34 +03:00
Дмитрий 458f206ce9 feat(смс): канал Теле2 — отправка, судьба сообщений и приёмник отчётов
Канал «SMS-Таргет» (target.t2.ru, HTTP API v2, Basic Auth) обеими половинами:

- отправка через POST /send_message (msisdn числом, shortcode, text);
- опрос судьбы GET /send_message/message-id-{id} — по одному сообщению,
  пакетного запроса у t2 нет;
- приёмник обратных звонков GET|POST /api/webhook/t2-delivery/{secret}
  со строками ?id=&status=&parts= — форма другая, чем у МТС.

Ловушки, закрытые тестами:

- слово status в ответе встречается дважды: снаружи «запрос удался»,
  внутри судьба сообщения. Спутать = объявить доставленным всё подряд;
- отказ приезжает с HTTP 200 — судим по телу, не по коду;
- незнакомое слово статуса судьбу НЕ меняет, сохраняется в delivery_raw.
  За «не доставлено» клиенту возвращаются деньги (В-198) — выдумка двигала
  бы рубли;
- t2 наш ответ не проверяет и повторов не делает: пропущенный звонок потерян,
  поэтому опрос client-sms:poll-delivery остаётся обязательным.

Деньги приёмник не двигает — возврат живёт в команде опроса (В-146).
Защита приёмника как у МТС: секрет не короче 32 знаков (пустой = закрыт
наглухо), список адресов отправителя, счётчик обращений; чужому 404, не 403.

Канал ВЫКЛЮЧЕН и живьём не пробован: имя отправителя Liderra.ru (заявка
№ 31121) на согласовании, без него t2 не выдаёт логин с паролём.
Порядок включения — docs/superpowers/runbooks/2026-08-04-vklyuchenie-kanala-t2.md

Миграций нет: графы судьбы появились ещё при МТС. Теле2 уже был в списке
разрешённых операторов и в тарифах админки — новых экранов не нужно.

Проверено: 78 тестов зелёные, Pint чист, статанализ по ВСЕМУ проекту 0 ошибок,
маршрут зарегистрирован. Против main — только вставки, удалений нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:06:01 +03:00
Дмитрий ec026f1b7b fix,смс: час по названию региона — Москва и Питер записаны в справочнике парой
Найдено приёмкой на живом справочнике боевого сервера. Оператор определялся
верно, а часовой пояс у Москвы и Санкт-Петербурга — нет: у 24 063 диапазонов
из 453 080 кода субъекта нет вовсе, потому что регион там записан ПАРОЙ —
«г. Москва и Московская область», «г. Санкт-Петербург и Ленинградская область»,
«Архангельская область * Ненецкий автономный округ». Однозначного субъекта у
такой строки действительно не существует.

Это самые крупные по числу абонентов диапазоны страны. Без разбора пары
оператор проставлялся, а пояс нет — и сообщение всё равно не уходило, потому
что без пояса номер ждёт уточнения региона, решение владельца В-85.

Нам, в отличие от приёма лидов, точный субъект не нужен — нужен ЧАС, а у обеих
половин каждой такой пары он общий. Поэтому пара разбирается на половины по
разделителям « и » и « * », и час берётся ТОЛЬКО если все узнанные половины
сошлись. Не сошлись или ни одна не узнана — честное «не знаю»: молча выбрать
одну из двух значит однажды отправить человеку СМС ночью.

Заодно поправлено утверждение в шапке класса: ключ ДаДаты на боевом сервере
ЕСТЬ, это проверено командой. Почему обращение к ней не дало оператора,
установить не удалось — падений в журнале нет ни одного.

Модуль СМС 402 из 402, статанализ 0.
2026-08-03 12:28:23 +03:00
Дмитрий d4ccb5ac95 fix,смс: оператор и часовой пояс номера — из бесплатного справочника Россвязи
Модуль спрашивал и оператора, и регион ТОЛЬКО у платной ДаДаты, а без её
полезного ответа выходил молча. На бою это убило две трети модуля: рассылки
«по своей базе» и «списком руками» не отправляли ни одного сообщения —
причина skipped_unknown_operator, живой прогон 03.08.2026.

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

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

Сторож считает ЗАПРОСЫ: предпросмотр списка на 20 000 номеров, спрашивая
справочник по одному номеру, давал 20 044 запроса и 22 секунды на одно
нажатие кнопки. Пачками — 64 запроса и 4,6 секунды.

Тесты писались первыми и проверены красными; сторож пачечного запроса
проверен вырезанием. Модуль СМС 399 из 399, статанализ 0.
2026-08-03 12:05:13 +03:00
Дмитрий 82ffa17a24 test: денежная проверка приёмника МТС считает проводки своего клиента, а не всей базы
Тест «денег НЕ двигает» падал только в полном прогоне: ждал ноль проводок
по всей таблице, а видел 19. Поодиночке был зелёный - значит виноват не он
и не код приёмника.

Виновник найден и назван: тридцать файлов рекламной папки идут БЕЗ отката.
Откат им не выдаётся и оптом - в tests/Pest.php строка RefreshDatabase
закомментирована. Их проводки остаются в базе и достаются соседям.
По всей папке Feature таких файлов 96 из 580.

Датчик, которым нашёл: после прогона папки смотреть остаток в базе снаружи,
через psql. База пересобирается в начале прогона, поэтому всё, что осталось
после - вина этого прогона. Три папки, четыре минуты, ровно те же 19 строк,
и они видны поимённо: пополнения и заморозки рекламных кампаний.

Правка узкая: считаем проводки СВОЕГО клиента. Смысл проверки не изменился -
приёмник не имеет права двигать деньги клиента, чьё сообщение он тронул.

Приёмка вырезанием: заставил приёмник вернуть деньги этому же клиенту -
тест покраснел; убрал - позеленел. Боевой код не тронут, git diff по app/app
пуст. Прогон трёх папок: 989 из 989 зелёных, при том что 19 чужих строк
в базе так и лежат - тест к соседям больше не чувствителен.

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

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

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

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

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

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

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

Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы.
Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали —
просим первую.
2026-08-01 10:37:50 +03:00
Дмитрий dd83f440eb feat(смс-клиент): приёмник отчётов о доставке от МТС — ускоряет опрос, не заменяет его
Строка листа 5.2. Появился адрес, на который МТС может присылать судьбу сообщения сам,
не дожидаясь нашего вопроса. В ответ отдаём код 204 — этого он требует, иначе считает
доставку неудачной и шлёт повторы.

Несущее решение здесь одно, и из него растёт всё остальное: приёмник — НАДСТРОЙКА, а не
замена. Опрос каждые десять минут остаётся на месте. Значит любой отказ приёмника
безобиден: не понял письмо, не узнал номер сообщения, вовсе выключен — всё доберёт опрос.
Поэтому везде выбран ноль вместо догадки, и это позволило честно работать при незнании.

А незнание крупное: формы письма, которое шлёт МТС, живьём не видел никто. Записано только,
что письма приходят и что отвечать надо кодом 204. Документации тут веры нет — она уже
соврала про опрос, описав одну форму вместо другой. Выдумывать я не стал (В-243): приёмник
разбирает ровно ту форму, которую видел живой ответ на опрос, а незнакомое письмо кладёт в
журнал сервера своей ФОРМОЙ — перечнем полей, без содержимого, потому что внутри телефоны, а
это персональные данные. Первый же живой отчёт покажет свою форму сам, и читатель дописается
одной правкой. Проверено живьём: телефон в журнал не утёк.

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

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

Адрес публичный, поэтому защита тройная: секрет в адресе (не задан — приёмник закрыт наглухо,
а не «пускать всех»), необязательный список адресов отправителя и счётчик обращений. Чужому —
404, существование приёмника не подтверждаем. Список адресов пока пуст: адреса МТС нам
неизвестны, и придумать их нельзя.

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

Живьём: верный отчёт правит строку и отвечает 204; чужой секрет — 404 и строка не тронута;
непонятное письмо — 204 и ни одной записи; отчёт про неизвестный номер — 204 и предупреждение;
адрес вне списка — 404. Кошелёк за весь прогон не шелохнулся. Стенд возвращён.

Включение — сторона владельца: адрес указывается в кабинете МТС. До этого всё работает опросом.
🔴 И честно: пока канал МТС на бою не отправляет ничего, проверить приёмник живым письмом
нечем — отчётам просто неоткуда взяться.
2026-08-01 03:02:47 +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
Дмитрий 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
Дмитрий 9f3ac393cb feat(смс-клиент): итог рассылки — доставлено, не доставлено, ещё в пути
Строка листа 5.3. До этого экран знал только «отправлено 900» — то есть сколько
сообщений мы отдали оператору. Дошли ли они до людей, портал не знал вовсе, а платит
клиент именно за то, чтобы дошли.

Новый счётчик ClientSmsDeliveryCounter — единственный дом этого счёта, как SmsQuietHours
для окна 10–20 и ClientSmsVolumeCounter для месячного объёма. Итог отдаётся и в карточке
рассылки, и в списке рассылок (одним запросом на все пятьдесят, а не по одному).

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

Счётчик ставит пометку клиента САМ (В-221): у журнала сообщений изоляция принудительная,
и запрос без пометки под рабочей ролью возвращает ноль строк молча — экран написал бы
«доставлено 0». На стенде эта дыра невидима в принципе (тесты идут под postgres), поэтому
проверена живым прогоном под ролью crm_app_user: со счётчиком 3/1/1/2, тот же запрос без
пометки — ноль.

Тесты: 5 новых, каждый проверен вырезом. Тест списка рассылок пришлось починить (В-223):
со списком из ОДНОЙ рассылки он не отличал «итог своей» от «итог первой попавшейся» и не
краснел на такой подмене — теперь в нём две рассылки с разными числами.

Отдельная запись про приборы (В-222): мой собственный скрипт вырезов соврал — падение
стенда он засчитывал как зелёный тест, а один вырез я забыл вернуть. Обе поломки чинены
в скрипте, а не обойдены.
2026-07-31 08:52:06 +03:00
Дмитрий fc235bb5a6 feat(смс-клиент): недоставленное закрывается через трое суток, деньги возвращаются клиенту
Этап 5, Task 4. ДЕНЕЖНАЯ задача — решения владельца В-198 и В-199.

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

ИДЕМПОТЕНТНОСТЬ ДВОЙНАЯ, и проверено, что нужны обе опоры:
  - ключ события sms:refund:{рассылка}:{номер} + уникальный ключ в базе;
  - отметка refunded_at на сообщении, ставится в ТОЙ ЖЕ транзакции.
Вырез только ключа тест НЕ красит (спасает отметка). Вырез только отметки тоже.
Сняв ОБЕ, тест краснеет числом 117.00 вместо 108.50 — деньги вернулись дважды.
Так и должно быть: одна опора страхует другую.

Вырезы (все вернуты): убрать «оператор не отправил» из возвращаемых -> 1 красный;
убрать закрытие по сроку -> 3 красных; возвращать за ЛЮБУЮ судьбу -> 2 красных
(значит тесты «за доставленное не возвращаем» и «за в пути не возвращаем» —
настоящие приборы, а не зелень вхолостую).

ЖИВОЙ ДЕНЕЖНЫЙ ПРОГОН (sandbox выключен — в песочнице деньги не двигаются вовсе,
и прогон доказал бы НОЛЬ): баланс 100.00 -> 108.50 ₽, движение типа refund ровно
одно, отметка у недоставленного стоит, у доставленного нет. ВТОРОЙ заход той же
команды: баланс тот же, движений по-прежнему одно. Стенд возвращён в исходное.

Прогоны: модуль 343/343 (13 пачек, все с первой попытки), рекламный кошелёк 5/5
(я трогал общий AdWalletService), phpstan ровно 2 чужие давние, pint чисто.
2026-07-31 06:36:27 +03:00
Дмитрий 5356bf3ed6 feat(смс-клиент): опрос судьбы отправленных сообщений у операторов
Этап 5, Task 3. Появилась команда client-sms:poll-delivery — каждые десять минут
спрашивает у канала, что стало с отправленными сообщениями, и проставляет судьбу
в журнал.

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

ЖИВОЙ ПРОГОН ПОД БОЕВОЙ РОЛЬЮ, тройкой (политика srv_bypass воспроизведена тем же
текстом, что в db/03, — на стенде их нет ни одной):
  А) право UPDATE есть + политика есть -> «уточнено: 1», судьба delivered;
  Б) права UPDATE нет -> команда ПАДАЕТ «нет доступа к таблице» (видно);
  В) права есть, политики нет -> «успех», «уточнено: 0», судьба пустая (НЕ видно).
Подтверждает В-181: у двух опор разная цена отказа, и опаснее вторая. Стенд
вернулся в исходное, политик srv_bypass не осталось.

ПРО ПРИБОРЫ — два теста оказались НЕ приборами, починены, а не подогнаны:
- «незнакомое слово не меняет судьбу» не отличал «графу не тронули» от «записали
  пусто» — теперь у сообщения есть стартовая судьба, и проверяется, что она цела;
- «пробное не спрашиваем» краснел бы не от того: пробное отсекает фильтр по
  КАНАЛУ, а не по статусу. Переименован по тому, что реально охраняет, и под две
  оставшиеся защиты заведены свои тесты.
После починки каждый из четырёх вырезов добавляет ровно один красный тест.

Прогоны: модуль 336/336 (13 пачек, все с первой попытки), phpstan ровно 2 чужие
давние, pint чисто. Команда в расписании — проверено schedule:list.
2026-07-31 06:23:24 +03:00
Дмитрий 36c8ced41e feat(смс-клиент): судьба сообщения отдельной графой + право служебной роли править журнал
Этап 5, Task 2. Схема v9.23 и v9.24.

Графа судьбы (миграция 101100): семь колонок у client_sms_messages —
delivery_status, delivered_at, delivery_checked_at, delivery_raw,
provider_cost, provider_parts, refunded_at, плюс индекс «кого спрашивать дальше».

Почему графа, а не переписывание status: по status считаются ДЕНЬГИ и месячный
объём клиента (ClientSmsVolumeCounter берёт ровно sent), его же складывает сторож
зависших и разбирают два словаря подписей на экране. Заменив sent на delivered,
мы обнулили бы клиенту накопление за месяц и удешевили бы цену задним числом.
Значит status остаётся фактом ПЕРЕДАЧИ, а судьба живёт рядом (В-202).

Цена оператора (provider_cost) хранится КАК ЕСТЬ: единицы поля cost у МТС
неизвестны (В-211), в рубли не переводится и ни во что не подставляется.

Право служебной роли (миграция 101200): GRANT UPDATE на client_sms_messages.
Команда опроса судьбы кросс-клиентская, ходит служебным соединением и ПРАВИТ
существующую строку — новых не пишет намеренно (В-204: новые строки делали бы
зависшую рассылку «живой» для сторожа, и деньги остались бы замороженными).
Без права на бою команда падала бы каждые десять минут; локально не видно
никогда — dev и тесты ходят суперпользователем (В-126/В-127).

Проверено:
- сторож MigrationGrantsTest расширен и проверен ВЫРЕЗОМ: гашение GRANT в
  миграции красит его;
- изоляция цела — запросом к базе после наката: RLS true/true, политика
  tenant_isolation на месте, права ролей ровно ожидаемые, все семь колонок есть;
- весь модуль 327/327 (было 320, +7 читателя судьбы), 13 пачек, все с первой
  попытки; phpstan ровно 2 чужие давние, pint чисто.
2026-07-31 06:04:56 +03:00
Дмитрий 8177cac39b feat(смс-клиент): канал умеет рассказать судьбу сообщения — разъём и читатель МТС
Этап 5, Task 1. Кода, который что-то меняет в базе или в деньгах, здесь нет —
только новая способность канала и её читатель для МТС.

Что появилось:
- SmsDeliveryState — словарь судеб ПОРТАЛА (везу / доставлено / не доставлено /
  оператор не отправил). Один дом на все каналы: у МТС свои слова, у следующего
  оператора будут другие, а экран и деньги обязаны говорить одним языком.
- SmsDeliveryReport — что канал рассказал про одно сообщение.
- SmsDeliveryReporter — НЕОБЯЗАТЕЛЬНАЯ способность канала. Отдельно от SmsProvider
  намеренно: канал, умеющий только отправлять, остаётся годным каналом. Когда у
  Т2, Мегафона и Билайна появятся кабинеты, их файл реализует этот разъём — и
  журнал, экран, деньги, итоги не тронутся ни строчкой (решение владельца 31.07).
- MtsSmsProvider::fetchDelivery — читатель, до 1000 номеров сообщений за запрос.

Форма ответа взята из ЖИВОЙ пробы 30.07 (журнал В-211), а НЕ из документации МТС:
там описан вход по логину-паролю (events_info), а мы ходим по токену и получаем
data[].statuses[]. Тест на выдуманной форме охранял бы выдумку (В-72).

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

Цена МТС (cost) сохраняется как есть, в рубли не переводится: единицы поля
неизвестны, выдумывать ответ внешней стороны запрещено.

Проверено: 7 тестов зелёные, оба выреза покраснили ровно те тесты, что должны
(подмена NotSent на «не доставлено» и угадывание незнакомого слова). Соседи —
36/36. phpstan — ровно 2 чужие давние, pint чисто.
2026-07-31 05:46:20 +03:00
Дмитрий b6c837e28a chore(смс-клиент): приёмка Этапа 4 — формат по всей области модуля, а не по свежим файлам
Task 8, приёмка Этапа 4. Кода она не приносит по замыслу: все двенадцать строк
листа (4.1–4.8, 4.11–4.14) закрыты ДВУМЯ доказательствами каждая — тест И живой
прогон, — экранные строки отдельно посмотрены глазами в браузере. Итоговая
таблица Этапа 4 заполнена в приёмочном листе (лист в git не лежит).

В git уходит ровно одна правка — порядок импортов в шести файлах тестов (В-192).
Причина, по которой она вообще нашлась: три этапа подряд `pint` гонялся ТОЛЬКО
по свежим файлам, а по всей области модуля не гонялся ни разу. Заодно выяснено,
что жалобы `line_ending` чинить не надо — это виндовые переводы строк рабочей
копии, в git их нет вовсе (pint «исправил», git не увидел ни одного изменения).
Перегнаны тесты этих шести файлов: 41/41 зелено.

🔴 ДЕНЕЖНАЯ МИНА СВЕРХ ПОСТРОЕННОГО (В-190). Права спросил у самой базы матрицей
по всем таблицам модуля — и увидел, что у рабочей роли crm_app_user есть право
писать в таблицу заморозок, а на её счётчик номеров ad_wallet_holds_id_seq права
нет. Живой прогон парно: без права заморозка денег под боевой ролью ПАДАЕТ («нет
доступа к последовательности»), с правом идёт. Заморозка делается при КАЖДОМ
заказе рассылки, при заказе имени и при запуске рекламной кампании Яндекса.
Промах был мой: в В-142 я привёл стенд к эталону двумя точечными командами по
именам из плана вместо `ON ALL SEQUENCES`, как делает сам db/02_grants.sql.
Памятка выката переписана: вместо списка имён счётчиков — запрос, который САМ
находит все счётчики без права. На бою проверить (косвенно там всё в порядке —
рекламные кампании запускаются тем же кодом, но довод косвенный).

🔴 И ПЕРВЫЙ ЗАХОД ПРИЁМКИ ПРОШЁЛ «ЗЕЛЕНО», НЕ ДОКАЗАВ НИ ОДНОЙ ДЕНЕЖНОЙ СТРОКИ
(В-191): песочница гасит и возврат заморозки при срыве, и ночного работника
целиком. Датчик на будущее — не сдвинулась ни одна копейка, значит прогон не
доказал ничего, даже когда всё зелено.

Ещё три промаха своих же приборов: В-193 и В-194 (прогон не доходил до
состояния — сторож законно даёт попытке дожать её срок, а имя без отметки
согласования кнопка законно не включает: правда была в коде), В-195 (браузерный
замер читал таблицу рассылок вместо базы и «доказал» поломку, которой нет —
класс В-121, соврал прибор).

Живьём под боевой ролью crm_app_user: сторож зависших тремя заходами (пометка →
выдержка срока → срыв, заморозка 17.00 → 0.00); имя за долг вернулось и списало
ровно 100 ₽, отключённое владельцем осталось выключенным при 5 000 ₽; кнопка
включения отказала числами, без пометки клиента 404, с пометкой списала 600 ₽;
номера руками легли пятью видами записи; продолжение довело 5 из 5 за 42.50 ₽.
Под служебной ролью — чистка снимка тройкой (без права падение, без политики
srv_bypass «успешный ноль», с обоими удалено 3).

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

Стенд сверен со снимком «до» поле за полем и совпал. Намеренное изменение одно:
рабочей роли выданы права на ВСЕ счётчики схемы, как в эталоне db/02_grants.sql.

🟡 Открытый вопрос владельцу — В-182: номера в журнале сообщений живут без срока,
обязательство «90 дней» закрывает только снимок получателей.

Ветка НЕ влита в main и НЕ выкачена. Порядок выката: миграции →
db/03_service_bypass_policies.sql → подсчёт политик srv_bypass (+8) → права →
контрольный запуск чистки снимка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:05:08 +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
Дмитрий c516e73255 refactor(смс-клиент): убран мёртвый путь сборщика аудитории и фильтр по колонке, в которую никто не пишет
Найдено при чтении кода перед Task 11, вычищено по указанию владельца
«не оставляй хвостов» (журнал В-183).

Что было. Ветка `ClientSmsAudienceBuilder::fromManual` читала снимок
получателей и отсеивала строки по `expires_at`. При этом:
· колонку `expires_at` не заполняет НИКТО — ни контроллер, ни джобы, ни
  обогащение ДаДатой; на стенде она пуста во всех строках;
· саму ветку не зовёт НИКТО: номера, вписанные руками, контроллер берёт
  прямо из запроса и разбирает единственным домом
  (`normalizeManualPhones`, строка листа 4.8).
То есть код обещал «у номера в списке есть срок годности» — поведение,
которого в портале нет. Опасность не теоретическая: я сам, начиная Task 11,
чуть не стал считать возраст снимка по этой колонке.

Что стало. Ветка убрана, на её месте ГРОМКИЙ отказ с объяснением, а не
пустой массив: пустая аудитория читалась бы как «рассылка честно ушла на
ноль номеров» — тот самый молчаливый сбой (В-121). Тест переписан на новое
поведение, мёртвый импорт убран.

🔴 Саму колонку `expires_at` НЕ удалял, и это осознанно: удаление
необратимо, а что лежит в ней на БОЕВОЙ базе, проверить нельзя — она по
умолчанию только для чтения и только с разрешения владельца. Оставлено
названным хвостом в приёмочном листе (п.30 «Чего эта работа НЕ делает»):
удаление — отдельная миграция со своим доказательством.

Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов
17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 08:13:07 +03:00
Дмитрий 6e29acb7af feat(смс-клиент): снимок получателей живёт 90 дней и чистится сам
Строка листа 4.12, решение владельца В-45. Снимок получателей — копия
ПЕРСОНАЛЬНЫХ данных: список номеров рассылки с пометками, кому уйдёт и кому
нет. Делается один раз при создании рассылки и больше не пересматривается
(В-39), то есть после отправки лежит мёртвым грузом. Хранить его вечно нельзя.

Что сделано:
· команда `client-sms:purge-snapshots` в расписании раз в сутки в 03:30
  (проверено schedule:list) уносит строки снимка старше 90 дней. Ходит
  служебным соединением — она кросс-клиентская и пометку клиента не ставит;
· пачками по 5 000: снимок бывает на 20 000 строк, одним DELETE по дате это
  долгая блокировка. Прибор стоит именно на цикл — 5 001 строка обязана
  уйти целиком, иначе одна строка ПДн осталась бы жить вечно;
· рассылка и её итоги НЕ трогаются: `client_sms_campaigns` и журнал
  сообщений остаются, как велел владелец. Цена этого названа вслух (В-178):
  через 90 дней уже нельзя ответить, кто из получателей ждал своего утра;
· срок живёт в коде, в админке не правится (В-177): срок зависания рассылки —
  рабочая настройка, а 90 дней — обязательство про персональные данные, одно
  для всего портала. При ручном запуске срок передать можно, нулевой
  отклоняется человеческими словами — он снёс бы снимки живых рассылок;
· про снимок НЕзакончившейся рассылки команда говорит вслух и в журнал
  сервера (В-176): в норме такого не бывает, и молчать об этом нельзя.

Право `DELETE` служебной роли — миграция 2026_08_01_100900, схема v9.21
(В-152). Номер и версию взял по каталогу: названные планом были заняты
Task 5 — третий раз этот класс (В-175). Сторож `MigrationGrantsTest`
расширен и спрашивает саму базу, а не текст миграции.

🔴 Живой прогон под боевой ролью crm_supplier_worker УТОЧНИЛ мину В-152
(В-181). На стенде разрешающих политик srv_bypass нет вовсе (В-179), поэтому
бой воспроизведён: политика поставлена тем же текстом, что в
db/03_service_bypass_policies.sql, и после прогона убрана. Тройка:
(1) политика есть, права нет — команда УПАЛА «нет доступа к таблице», строки
целы (план предсказывал «удалит ноль и отрапортует успехом» — в жизни
падение); (2) право есть, политики нет — «Удалено строк снимка: 0» с кодом
УСПЕХА, вот настоящий тихий ноль; (3) обе опоры — удалено 3 из 4, свежая
строка на месте, рассылка и её сообщение на месте, предупреждение о
незаконченной рассылке прозвучало. Вывод для выката: без права беда ВИДНА
(падение), без политики НЕ видна (успешный ноль).

Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов
17/17, phpstan 2 чужие давние, pint чисто. Фронт не трогался вовсе — vitest
и vue-tsc не гонялись, правок в экранах нет ни одной. Вырезов восемь, каждый
покраснел ровно там, где вырезан; девятый — подмена служебного соединения на
обычное — НЕ покраснел вообще (6/6 зелёных), и это честный результат: чем
ходит команда, ловится только живым прогоном. Стенд возвращён и сверен со
снимком ДО; намеренное изменение одно — миграция накатана на dev-базу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 07:47:02 +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
Дмитрий 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
Дмитрий f25c2f550d feat(смс-клиент): имя, отключённое за долг, возвращается само — за один месяц вперёд
Строка листа 4.4, Этап 4 Task 3.

Раньше ночной работник смотрел только на работающие имена, поэтому имя,
отключённое за долг, не возвращалось само никогда и ни при каких деньгах.
Теперь у него есть второй проход: как только у клиента хватило СВОБОДНЫХ денег
(с учётом замороженных под рассылки), имя включается, а плата берётся за один
месяц вперёд от сегодняшнего дня. Старый долг прощается — решение владельца
В-133: за месяцы, когда имя не работало, брать не за что. Документы заново не
запрашиваются: заявка не пересоздаётся, меняются только состояние и срок оплаты.

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

Плана оказалось мало (журнал В-140). Он предлагал возвращать всё, что
«отключено и с долгом», но имя отключает и владелец руками из админки —
и такое имя погашено у МТС, оно не работает. Портал включал бы его обратно
и списывал за это деньги клиента, отменяя решение владельца. Отличать по тексту
записки нельзя: текст — не признак. Поэтому у имени появилась графа «почему
отключено» (за долг / рукой владельца); сам портал возвращает только первое.
Схема v9.18, миграция 2026_08_01_100600, прав не требует.

Второй правкой плана (В-141) переписан его тест на двойное списание: он проходил
вхолостую, потому что после первого прогона имя уже работает и второй проход его
не видит. Настоящая опасность — кнопка клиента (строка 4.5) и работник спишут за
один месяц дважды, если ключ у них разный. Тест теперь про это и краснеет от
порчи ключа.

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

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

Прогон вскрыл чужую мину (В-142): у боевой роли не было права на счётчик номеров
таблицы движений рекламного кошелька — под этой ролью молча не проходило ни одно
списание. Это расхождение локального стенда с эталоном db/02_grants.sql, кода не
касается; в памятку на выкат вписана читающая проверка этого права на бою.

Прогоны: клиентские СМС 262/262 (11 пачек, все с первой попытки), приём лидов
17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался.
2026-07-29 11:30:41 +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
Дмитрий c0a8da37d2 feat(смс-клиент): зависшая рассылка сама срывается и возвращает замороженные деньги
Строки листа 4.1 и 4.2, Этап 4 Task 1.

Беда, от которой сторожим: работник очереди умирает посреди отправки
(перезапуск сервера, обрыв связи). Снятие заморозки денег стоит ПОСЛЕДНИМ
шагом джоба отправки — до него он в этом случае не доходит. Итог на бою:
рассылка вечно «идёт», деньги клиента заморожены навсегда, в журнале тишина.

Команда client-sms:watch-stuck, каждые 15 минут. Движение меряется временем
последней записи в журнале рассылки: sent_count для этого не годится, он
проставляется только в самом конце. Порядок — решение владельца В-132:
сперва ОДНА попытка дожать (это безопасно, джоб пропускает уже отправленные
номера по ключу на номер), и только если и после неё не сдвинулась — срываем,
размораживаем остаток, ставим причину «сторож». Итог считаем из журнала.

Честно ждущая утра рассылка не трогается вовсе (строка 4.2): отличаем по
состоянию — ждущая waiting_window, зависшая sending. Ждущих дожимает
client-sms:resume-waiting.

Миграция 2026_08_01_100500 — две колонки: срок «зависла» в настройках
(правит владелец, строка 4.3) и отметка попытки дожать у рассылки. Прав не
требуют, наследуют привилегии таблиц; повторный накат переживают. Схема v9.17.

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

Живой прогон ПОД БОЕВОЙ РОЛЬЮ crm_app_user, парно:
с пометкой клиента — рассылка сорвана, заморозка 17.00 → 0.00;
без пометки — осталась «идёт», 17.00 зависли,
и в ОБОИХ случаях команда сказала «Сорвано: 1» и вернула успех.

Прогоны: ClientSms 249/249 (было 241, +8; 11 пачек, все с первой попытки),
приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался.
2026-07-29 09:44:42 +03:00
Дмитрий d242819e27 fix(смс-клиент): три блокера выката — снимок читался без пометки клиента, правка снимка и базы была без прав
Продолжение приёмки Этапа 3 по указанию владельца: «проверь замечание — так это или нет —
и посмотри всё окружение на этот класс ошибок». Замечание оказалось верным, и рядом с ним
нашлось ещё два блокера того же семейства. Ни один из трёх не виден ни одному тесту.

🔴 В-125. Джоб отправки читал снимок получателей ВНЕ пометки клиента: строки 115 и 139
стояли голыми в handle(), а обёртка открывалась только внутри цикла. Чтение снимка ленивое —
запрос уходит не тогда, когда читателя позвали, а когда забирают очередную пачку, то есть
уже за пределами чужой транзакции, а SET LOCAL живёт только до её конца.

Замер у самой базы на ОДНОЙ строке снимка: суперпользователь (так идут все наши тесты) —
1 строка, боевая роль crm_app_user без пометки — 0, она же с пометкой — 1. Без ошибки,
молча. На бою: рассылка закрывается «готово, отправлено 0»; а если в снимке есть ждущие
своего утра — статус «ждёт утра», заморозка НЕ снимается, команда добора будит рассылку
каждые 15 минут, та снова читает ноль, и деньги висят замороженными без конца.

Починка стоит в самом читателе, а не у зовущего: он оборачивает КАЖДЫЙ свой запрос сам.
Полагаться на внимательность каждого, кто его позовёт, оказалось нельзя — ровно на этом
дыра и выросла. Зовущим не мешает: под запросом человека пометка уже стоит, вложенная
транзакция ставит то же значение.

🔴 В-126 и В-127. Этап 3 научил двух помощников ПРАВИТЬ снимок и клиентскую базу —
проставлять найденный у ДаДаты регион, пояс и оператора. А обе таблицы заводились под путь
«строки только вставляют и удаляют»: прав на правку им не выдавали. Замер: UPDATE под
боевой ролью — «нет доступа к таблице», SELECT той же ролью работает.

На бою: номер, у которого пояс не был известен сразу, не получил бы его НИКОГДА, а номер
без пояса не отправляется вовсе (решение владельца В-85). Для канала «своя база» пояс не
известен ни у одного номера, пока помощник его не проставит, — то есть этот канал не
отправил бы ни одного сообщения.

Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись
схемы v9.16. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, и
обещание опять оказалось неполным — как в В-122 неделей раньше.

⚠️ В-128, не чиню, называю. В трёх докблоках записано, будто служебная роль обходит изоляцию.
ПИЛОТ.md от 07.07: на боевом кластере её не обходит НИ ОДНА роль. Значит служебное соединение
живо не обходом, а политиками srv_bypass, и перезапуск db/03_service_bypass_policies.sql при
выкате — не подстраховка, а несущая опора. Правка текстов — уровень всего приложения, не этапа.

Доказательства.
· Тест В-125 проверяет не результат (его подделать нельзя — суперпользователь всё видит), а
  ПОРЯДОК: каждый запрос к снимку обязан идти внутри той же открытой транзакции, где уже
  выставлена пометка. Уровень вложенности отличает «пометка здесь и сейчас» от «стояла раньше,
  в другой, уже закрытой». Красный до починки показал 5 чтений, все без пометки.
· Живой прогон НАСТОЯЩЕГО джоба под боевой ролью (SET ROLE crm_app_user), парно:
  с починкой — «отправлено 2 из 2, статус готово»; без починки — «отправлено 0 из 2, статус
  готово, джоб не упал». Тот самый молчаливый сбой, вживую.
· Права: живой UPDATE под боевой ролью — до миграции «нет доступа» по обеим таблицам, после
  миграции обе правки проходят. Данные пробы откатаны.
· Вырезами трижды: убрал обёртку у чтения снимка — тест краснеет; опечатка в имени роли ВНУТРИ
  гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; выдал права
  только одной таблице из двух — краснеет на второй. Всё возвращено.

Обход всего окружения на этот же класс: 35 фоновых помощников и 36 команд классифицированы по
тому, ставят ли они пометку клиента и каким соединением ходят. Те, что работают без пометки,
трогают только таблицы БЕЗ изоляции (портал продаж, бот, внешние балансы). Отдельно искал именно
ловушку «ленивое чтение уезжает из чужой обёртки» — в рекламном модуле и сборщике аудитории всё
внутри. Права на автономера у всех новых таблиц выданы обеим ролям, по кошельку перекосов нет.
Не проверял вглубь маршруты портала (их закрывает общая прослойка) и модули вне рекламы/СМС.

Прогоны: СМС 241/241 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных +
3 пропущенных (не трогал), phpstan 2 чужие давние, pint чисто. Стенд возвращён: dev-база — те же
12 клиентов и нули по модулю, пробные строки в тестовой базе откатаны.

🔴 При выкате ветки порядок прежний и обязателен: миграции → db/03_service_bypass_policies.sql →
контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Права и изоляция — разные
механизмы, эта миграция того шага не заменяет.
2026-07-29 08:34:02 +03:00
Дмитрий 6704054a80 fix(смс-клиент): приёмка Этапа 3 — служебная роль получила права, экран перестал врать
Этап 3 «Время и цена», Task 8 — приёмка. Строка листа Н.2 (проверка разграничения
доступа), итоговая таблица этапа заполнена.

🔴 Блокер выката, найден проверяющим по доступу и подтверждён лично.
Команда добора `client-sms:resume-waiting` (появилась в этом этапе, стоит в расписании
каждые 15 минут) ходит в базу под служебной ролью `crm_supplier_worker`: она обходит
рассылки ВСЕХ клиентов и потому не может работать под клиентской ролью. Прав этой роли
на три таблицы, которые она читает и правит, выдано не было — миграции модуля выдавали
права рабочей роли, а служебной только на имя отправителя и правило авто-СМС.

Локально этого не видно: dev и тесты ходят суперпользователем. На бою команда падала бы
с «нет доступа» каждые 15 минут, и камчатская часть рассылок не дошлалась бы НИКОГДА.
Тот же класс, что блокер прав на счётчики 27.07.

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

Две неправды на экране, найденные живым прогоном на НЕудобных данных.
· Текст в 2325 символов: сервер отказал (потолок 1000), сметы нет, а строчка под текстом
  пишет «Умещается в 1 СМС за номер». В коде стояло `preview?.segments ?? 1` — на месте
  «не знаю» подставлялась единица и утверждалась как факт. Теперь без ответа сервера
  число СМС не называется вовсе: молчание честнее. Дыра не этого этапа (код от 26.07).
· Сам отказ был написан по-программистски: «Количество символов в поле body не может
  превышать 1000». Стало: «Текст длиннее 1000 символов — сократите его.»

Вырезами проверено четыре раза: опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки
не даёт и права молча не выдаёт) — сторож краснеет; опечатка в цели GRANT — краснеет;
убрал человеческий текст отказа — краснеет; вернул подстановку единицы — краснеет.
Всё возвращено.

Живой прогон строк 3.1–3.15 подряд (29.07, 06:45–07:00 МСК, песочница, локальная база):
камчатский номер ушёл сразу, московский стал ждать 10:00, без региона — ждёт уточнения;
экран сказал «Отправлено 1 из 3, 1 ждут утра в своих регионах, ещё 1 — уточняем регион»;
команда добора до утра дала 0, после наступления срока 1 и номер ушёл, повторный запуск
снова 0; ДаДата (ЗАГЛУШКА, боевой ключ не трогали) спрошена ровно один раз — про тот
номер, у которого пояса не было; авто-СМС на московский лид отложена ровно на 10:00, при
открытом окне три запуска дали одно сообщение; цена прошла 9.00 → 8.50 → 8.00 по
накоплению, у отправленной рассылки осталась 9.00; счётчик сложил 500 из рассылки и 500
из авто-СМС; песочные отправки в счётчик не пошли. Стенд возвращён ровно в исходное:
окно 10–20, 0 контактов, 0 сообщений, 0 рассылок, 0 снимков, 5 демо-сделок, кошелёк
1000.00 / заморожено 0.00.

Прогоны: СМС 240/240 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676
зелёных + 3 пропущенных, phpstan 2 чужие давние, vue-tsc 8 чужих давних (проверено
git blame), pint чисто.

🪤 Урок приёмки: мой собственный скрипт прогона отрапортовал «красных пачек нет» и НОЛЬ
зелёных — разбирал ответ не в том формате. Ноль почти всегда сбой, а не правда о мире.
Теперь скрипт считает зелёным только ответ, где прошедших БОЛЬШЕ НУЛЯ.

🔴 При выкате ветки порядок обязателен: миграции → db/03_service_bypass_policies.sql →
контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Эта миграция его НЕ
заменяет: без srv_bypass команда добора увидит тихий ноль даже с выданными правами.
2026-07-29 07:37:01 +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
Дмитрий 026e71a283 feat(смс-клиент): цена по накоплению за месяц, а не по объёму одного заказа
Этап 3 «Время и цена», Task 6. Строки листа 3.8, 3.9, 3.10, 3.14.

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

Счётчик живёт в одном месте (ClientSmsVolumeCounter): его зовёт цена, а в Task 7 позовёт
экран. Отдельного накопительного счётчика в базе не завожу намеренно — счётчик,
разъехавшийся с журналом, опаснее лишнего запроса; журнал правдив, потому что деньги
списываются той же записью.

Считаем в СМС, а не в сообщениях (В-108). Длинное письмо — это два СМС, и платит клиент
за два; считая строки журнала, мы держали бы его на дорогой ступени дольше обещанного, а
цифра на экране «в этом месяце отправлено N СМС» разошлась бы со списанными деньгами.
Ради этого в журнале появилась колонка segments (схема v9.14) и частичный индекс под
единственный запрос счётчика. Пусто у старых записей = одно СМС (В-109).

Граница месяца — по Москве, а не по Гринвичу (В-110): 31 июля 21:30 UTC это уже 1 августа
в Москве. Не путать с окном 10–20 — там время местное у получателя, здесь московское у
клиента.

Цена по-прежнему фиксируется в момент создания и джобом не пересчитывается (3.14).

🔴 Мина, найденная самопроверкой (В-114): авто-СМС считала бы объём месяца ВНЕ изоляции по
клиенту. В запросе экрана контекст ставит middleware, а в очереди — никто, и на бою
счётчик вернул бы честный ноль: клиента молча посчитали бы по самой дорогой ступени, без
единой ошибки в журнале. Тестами не ловится — на стенде изоляция не применяется. Счёт
переехал внутрь tenant-транзакции, как и деньги в том же джобе.

🧹 Убран прежний estimateRub (В-113): он считал смету по ступени для объёма одного заказа,
без накопленного, и больше не звался. Оставленный «на всякий случай» второй расчёт цены —
это место, которое однажды позовут, и цифры разъедутся.

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

Вырезанием проверено четырежды: вернул старый расчёт в контроллер — покраснел тест через
настоящий запрос экрана; убрал запись числа СМС в журнал — покраснел тест отправки;
засчитал песочные — счётчик дал 12 вместо 1; перенёс границу месяца на Гринвич — покраснел
тест границы.

Живьём на локальной базе (20:17 МСК, песочница, ДаДата заглушена): предпросмотр до
накопления 9.00 ₽, после 5 000 отправленных — 8.00 ₽; песочная рассылка ушла, в журнале
«СМС=1», счётчик месяца остался нулём; у прежней рассылки цена так и осталась 9.00 ₽, а
новый заказ уже шёл бы по 8.00 ₽. Стенд возвращён как был. Реальное списание по
накопленной ступени доказано тестом, а не живьём: в песочнице деньги не двигаются (В-81).

🪤 Урок В-111: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в
тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку.
2026-07-28 20:28:00 +03:00
Дмитрий d61b552532 feat(смс-клиент): ДаДата даёт регион и оператора всем номерам, снимок дозревает
Этап 3 «Время и цена», Task 5. Строка листа 3.3 закрыта целиком.

Номер, про который мы ничего не знаем, перестал быть тупиком: теперь его судьбу решает
ДаДата — и решает ОДНИМ платным вопросом, потому что пояс и оператор приезжают в одном
ответе. Входов обогащения три, и все три ведут в одно и то же место.

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

Рассылка: новый EnrichClientSmsSnapshotRegionJob спрашивает про строки снимка без пояса и
пишет туда и пояс, и оператора. У номеров, вписанных руками, оператора не было вовсе —
это и есть закрытие старой жалобы В-69. Ставится из контроллера сразу после снимка и
только если такие строки есть; узнав регион, сам будит рассылку, чтобы созревшие номера
не ждали четверть часа зря. Будит ТОЛЬКО ждущую утра: у остановленной клиентом рассылки
статус другой, и повторный запуск затёр бы ей «остановлена» на «готово».

Авто-СМС: сделка без региона спрашивает ДаДату по номеру лида (В-100) — иначе заведённый
руками лид не получил бы СМС и после этой задачи. За номер платим один раз: узнанный пояс
несётся в самом отложенном задании, а «ответила, но пояса нет» помечается отметкой; заново
спрашиваем только если вопрос не состоялся — сбой сети или выбранный дневной лимит (В-104).

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

Кого НЕ спрашиваем (В-105): строку, у которой пояс есть, а оператор пуст. Она и так уйдёт
универсальным каналом, вопрос был бы ради экономии на канале, а ДаДата — наши деньги:
рассылка на 20 000 номеров это до 12 000 ₽ при ещё не назначенном потолке расходов (В-90).
Платим только там, где без ответа сообщение не уйдёт вовсе.

Прогоны: СМС 217/217 (было 202, +15 новых), приём лидов 17/17, фронт 1663 зелёных,
phpstan 0, pint чисто. Миграций нет — колонки пояса завёл ещё Task 2.

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

Живьём на локальной базе (19:19 МСК, ДаДата заглушена, живой ключ не тронут): вписанные
руками номера узнали пояс и оператора, московский ушёл сразу, камчатский отложен до
10 утра его времени; номер, про который ДаДата молчит, через сутки получил причину и
рассылка стала «готово»; сделка без региона получила авто-СМС по ответу ДаДаты, а сделка,
про которую ДаДата молчит, ждёт дальше и в журнал не пишет. Стенд возвращён как был.

🪤 Урок В-106: повторный Http::fake() прежнюю заглушку не заменяет — отвечает первая, и
тест «а теперь ДаДата отвечает» молча проверяет старый ответ.

🟢 Запрет на выкат ветки СНЯТ (был из-за В-93): ни один источник больше не отправляет ноль.
Выкатывать всё равно рано — Этап 3 не закончен, цена считается по-старому.
2026-07-28 19:28:14 +03:00
Дмитрий 2460623ae0 feat(смс-клиент): ночной лид получает авто-СМС утром и ровно одно
Этап 3 «Время и цена», Task 4. Строка листа 3.6, дозакрыта 3.1.

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

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

Москву при пустом регионе не подставляем. План велел «нет региона — считаем московским»,
но эту запись владелец отменил (В-85, журнал В-98): вставь я её дословно, камчатский лид,
пришедший в московский вечер, получил бы СМС в четыре утра. Сделка без региона сутки ждёт
уточнения — джоб переставляет себя раз в час и молчит, — потом получает одну честную
запись skipped_unknown_region. Подписи причины заведены в обоих словарях экрана сразу
(ловушка В-70), хотя журнала авто-СМС на портале пока нет вовсе — это существующая дыра,
названа отдельно (В-102).

Старым тестам авто-СМС проставлен регион в фикстурах и зафиксирован час: тот же ремонт,
что в В-93 — боевое правило не ослаблял.

Прогоны: СМС 202/202 (было 195, 7 новых тестов), приём лидов 17/17, фронт 1663 зелёных,
phpstan 0, pint чисто, типы — те же 8 чужих давних. Миграций нет.

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

Живьём на локальной базе (18:15 в Москве, 03:15 на Камчатке): московский лид ушёл сразу,
камчатский отложен на 22:00 UTC — это десять утра следующего дня у него; свежая сделка без
региона отложена на час, двухдневная получила причину. Затем открыл окно камчатской и
запустил трижды — сообщение одно. Стенд возвращён как был.

🪤 Урок В-101: под тестовой очередью «переставить себя» исполняется немедленно — джоб зовёт
себя без конца и прогон виснет. В таких тестах обязателен Queue::fake().

⚠️ Ветку по-прежнему нельзя выкатывать до Task 5 (В-93).
2026-07-28 18:23:15 +03:00
Дмитрий 434d86d5e6 feat(смс-клиент): камчатская часть рассылки уходит сама, а экран объясняет ожидание
Этап 3 «Время и цена», Task 3. Строки листа 3.4 и 3.5.

Ждущая рассылка перестала быть тупиком. Раз в четверть часа команда обходит рассылки,
висящие «ждёт утра», и заново кладёт в очередь те, у которых что-то созрело. Человек не
нажимает ничего: заказал в московский полдень — камчатские номера уйдут в своё утро сами.

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

Команда работает вне запроса пользователя, то есть без tenant-контекста: рассылки
перечисляются служебным соединением (BYPASSRLS), а tenant_id уходит джобу явным аргументом.
Забыть это — получить на бою тихий ноль, тот самый класс поломки «srv_bypass».

Экран под статусом рассылки говорит человеческой фразой: «Отправлено 1 из 7, 1 ждут утра в
своих регионах, ещё 5 — уточняем регион». Два ожидания названы ПО ОТДЕЛЬНОСТИ: утро пройдёт
само, а регион сам не пройдёт, и написать про вторых «ждут утра» значило бы заставить
человека ждать зря.

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

Прогоны: СМС 195/195 (было 191, 4 новых теста), приём лидов 17/17, фронт 1661 зелёный,
phpstan 0, pint чисто. Вырезанием проверено трижды: убрал «есть что дослать» — покраснел
контрольный тест «пока утро не наступило, не трогаем»; убрал «этого номера ещё нет в журнале»
— он же; убрал из подписи фразу про регион — покраснел фронтовый тест.

Живьём на локальной базе: заказал через браузер рассылку на 7 номеров, ушёл один москвич,
камчатскому проставлено ожидание до 22:00 UTC, пятеро без региона ждут уточнения. Команда
руками до утра — «Дослать: 0 рассылок», после сдвига срока — «1 рассылок» и номер ушёл,
повторный запуск снова 0. Экран сам перестал говорить про утро. Стенд возвращён как был.

🪤 Урок В-95: команда находила НОЛЬ при явно ждущей рассылке — служебное соединение в тестах
не видит незакоммиченных данных (лечится трейтом SharesSupplierPdo). Ловушка врёт в обе
стороны: тест «ничего не ушло» был бы зелёным по неправильной причине. Поймал только потому,
что рядом стоял парный тест «а теперь должно уйти».

⚠️ Ветку по-прежнему нельзя выкатывать до Task 5 (В-93).
2026-07-28 16:44:30 +03:00
Дмитрий 1d8723f285 feat(смс-клиент): Калининград и Камчатка получают СМС каждый в своё утро
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.

У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.

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

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

Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).

Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).

Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.

В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.

Запись схемы v9.13.
2026-07-28 15:46:53 +03:00
Дмитрий 86d974330b feat(смс-клиент): правило «с 10 до 20 по местному» живёт в одном месте, границы правит владелец
Этап 3 «Время и цена», Task 1. Закрыты строки листа Н.3 и 3.7.
Поведение рассылки ещё НЕ меняется — этим займётся Task 2. Сейчас заведено то,
на чём оно будет стоять, и заведено так, чтобы правило нельзя было размножить.

1. Справочник часовых поясов (RegionTimezoneMap). 89 субъектов РФ в том же порядке,
   что и справочник имён; сторож-тест сверяет составы, чтобы справочники не разъехались.
   Отдельный тест на ловушку: код субъекта у нас НЕ автомобильный — 77 это Тюменская
   область, а Москва 82. Неизвестный код и непонятная строка от ДаДаты дают «не знаю»,
   а не ноль: ноль означал бы Гринвич, то есть тихую подмену Камчатки Лондоном.

2. Единственный дом правила 10–20 (SmsQuietHours): можно ли отдавать сейчас, когда
   откроется окно, осмысленно ли такое окно. Границы читаются из настроек один раз
   на объект — на 20 000 номеров иначе был бы 20 000-й запрос к базе.

3. Границы окна в общих настройках: миграция добавляет две колонки со значениями 10 и 20.
   Защита от повторного запуска пошаговая — прерванная ручная подача SQL на бою не должна
   оставить вторую колонку несозданной (урок В-80). Прав не требует: колонки наследуют
   привилегии таблицы. Запись схемы v9.12.

4. Админка «СМС»: два поля «Отправляем с / по» и объяснение, что часы — по местному времени
   получателя и клиент их не настраивает. Окно наизнанку «с 20 до 10» это отправка всю ночь,
   поэтому сервер его не принимает и говорит человеку почему. Проверяется ПОЛУЧИВШЕЕСЯ окно,
   а не присланные поля: правка одной границы тоже могла его вывернуть — журнал В-91.

Прогоны: СМС 186/186 (было 171, 15 новых тестов), приём лидов 17/17, фронт 1658 зелёных
и 3 намеренно пропущенных, phpstan по своим файлам 0, pint чисто, проверка типов без новых ошибок.
Вырезанием проверено дважды: убрал проверку окна — покраснели два теста; убрал чтение границ
с сервера на экране — покраснел фронтовый тест. Живьём: окно 11–19 сохранилось и пережило
перезагрузку, «с 20 до 10» отклонено с человеческим текстом, вернул 10–20.

Попутный урок В-92: два моих же новых теста сперва зеленели по неверной причине — ругань
приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо
и проверяют, за какое поле ругаются.
2026-07-28 15:20:13 +03:00
Дмитрий 51b9c20d0b fix(смс-клиент): повторный накат доделывает индекс ключа заказа + сторож прав по трём ролям
Две находки обязательного проверяющего доступа (приёмка Этапа 2, журнал В-80).
Обе — про молчаливые поломки: ошибок нет, тесты зелёные, защиты нет.

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

   Проверка стала пошаговой. Доказано вырезанием: до правки новый тест краснеет
   («защита от двойного заказа потеряна молча»), после — зелёный. Живьём на
   локальной dev-базе: индекс уронен руками, повторный накат его вернул.

2. Сторож прав спрашивал базу только про рабочую роль. А гардов с именами
   служебных ролей в миграциях семь, и опечатка внутри такого гарда ошибки НЕ
   даёт — права просто молча не выдаются. Ровно тот блокер выката, ради которого
   сторож и заводился (В-36).

   Теперь спрашиваем и crm_admin_user, и crm_supplier_worker — по матрице,
   сверенной со всеми GRANT'ами миграций, — и счётчики для всех трёх ролей.
   Доказано вырезанием: опечатка в имени роли внутри гарда → сторож краснеет с
   именем роли, таблицы и права.

Прогоны: СМС 171/171 (было 169, два новых теста), приём лидов 17/17, pint чисто.
Структура таблиц не менялась — запись в журнале схемы v9.11.
2026-07-28 13:56:14 +03:00
Дмитрий efcb8034a0 chore(смс-клиент): убрана врущая графа «кто внёс» + миграции модуля переживают повторный запуск
Хвосты Этапа 1 (журнал В-37). Строк приёмочного листа не закрывают — уборка.

1. Графа «кто внёс» в общем стоп-листе портала снесена. Она была не пустой, а
врущей: при открытом в том же браузере обычном кабинете туда записывался id
КЛИЕНТСКОГО пользователя — число, неотличимое от id администратора. Проверено
пробой: пользователь 1 → в графе 1. Админ-зона закрыта паролем nginx, своего
входа Laravel у неё нет, а сессия кабинета видна и там — то же эхо коллизии
24.07. Заполнить правдой нечем: настоящий вход админа ждёт Б-1, соседние экраны
пишут id служебной заглушки, то есть одно число во всех строках. Остались номер,
причина словами и дата — этого хватает и для разбора жалобы, и для договора с
МТС. Возврат — down() миграции.

2. Все 16 миграций модуля начинаются с «уже сделано — выходим», уникальный
индекс ключа заказа создаётся с IF NOT EXISTS. Причина не теоретическая: на бою
SQL миграций подаётся в базу руками, памяти «этот файл уже применён» там нет. У
тарифов и настроек это особенно важно — они засевают строки, и повторный запуск
завёл бы ВТОРУЮ строку настроек молча, без ошибки.

3. Восемь GRANT … TO crm_app_user стояли без проверки существования роли — на
чистой базе migrate падал бы целиком. Теперь все в гарде, как в v9.00 и v9.07.

Своя ловушка гарда: опечатка в имени роли внутри IF EXISTS ошибки НЕ даёт, права
просто не выдаются — а это ровно блокер выката В-36. Поэтому заведён сторож:
тест спрашивает у самой базы has_table_privilege / has_sequence_privilege по
каждой таблице и счётчику модуля. Заодно закрыта дыра — у фикса В-36 теста не
было вовсе.

Хвост «7 замечаний squawk» проверить его же инструментом нельзя: squawk читает
SQL, а миграции у нас PHP. Чужой отчёт не пересказываю — проверка своя и
воспроизводимая: тест прогоняет up() каждой миграции второй раз.

Тесты: +3 (повторный запуск, права ролей, «чужого следа не остаётся»), один
переписан. ClientSms 169/169, приём лидов 17/17, phpstan 0, pint чисто.
Три выреза, все покраснели: испорченное имя роли, снятая защита от повторного
запуска, возвращённая графа.

Живой прогон: вошёл в обычный кабинет (та самая опасная обстановка), внёс номер
через админ-раздел — в базе ровно три поля, чужого следа нет; убрал кнопкой.
Пять миграций запущены по второму разу прямо на dev-базе — прошли, тарифов 5,
строка настроек одна. Контроль: голый CREATE TABLE та же база отвергает.

Запись схемы — v9.10.
2026-07-28 11:42:37 +03:00
Дмитрий 5b7d6e1bab fix(смс-клиент): «не определён оператор» вместо неправды «номер не из МТС»
Строка приёмочного листа 2.3. Раньше номер с НЕИЗВЕСТНЫМ оператором и номер
чужого оператора давали одну причину, и клиент читал про свой номер неправду:
«не из МТС» — хотя чей он, мы не знаем. Теперь причины две, и обе правдивы.

Так приходит большинство таких номеров: вписанные руками — без оператора
всегда, номера «своей базы» — до того, как ДаДата его проставит.

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

Два капкана, оба доказаны вырезанием, а не рассуждением:

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

Деление. Словарь операторов отдаёт пустоту и когда оператора нет, и когда имя
есть, но словарь его не знает («Тинькофф Мобайл»). Делим по сырому значению,
иначе получилось бы новое враньё в другую сторону.

Тесты: +5 (три причины врозь, сторож универсального канала, журнал рассылки —
чтобы предпросмотр и журнал не расходились в словах). ClientSms 165/165, приём
лидов 17/17, фронт 1656 зелёных, phpstan 0, pint и eslint чисто.

Живой прогон: одна сводка сразу показывает и правду, и контроль — «Уйдёт 1 СМС
— 9.00 ₽ · Не уйдёт: не определён оператор — не знаем, куда слать — 1 · номер
не из МТС (пока шлём только по МТС) — 1». В журнале рассылки та же правда.

Экран пока НЕ подсказывает, что оператор ещё выясняется — отдельная работа,
записана в «Чего эта работа НЕ делает» п.16.
2026-07-28 10:58:45 +03:00
Дмитрий 537d3e9c4d perf(смс-клиент): 20 000 номеров не подвешивают портал
Строка приёмочного листа 2.9. Две правки на пути, который ждёт человек у экрана:
предпросмотр сметы и создание рассылки.

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

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

Третье место из плана — квадрат в джобе отправки — было вылечено ещё в задаче
про снимок получателей; своей работой не считаю.

Замеры по два прогона на сторону, на одной базе. Предпросмотр 20 000: память
сверх маленькой рассылки 37 → 18 МБ, время 0.56 → 0.25 с. Создание 20 000:
1.4 → 1.05 с — разница мала, победой не называю. Большой чужой стоп-лист
(50 000) при 200 своих номерах: +27 → +4 МБ.

Тесты: 4 новых (три на объём, один сторож). Меряют разницей «маленькая
рассылка → большая», а не абсолютом: цена самого запроса так вычитается.
ClientSms 162/162 два прогона подряд, приём лидов 17/17, phpstan 0.

Живой прогон: 20 000 контактов в локальной базе. Предпросмотр 0.8 с, создание
кнопкой 1.9 с, снимок 20 000 из 20 000. Обе рассылки очередь отправила целиком
(40 000 сообщений, ≈148 в секунду в песочнице); во время отправки портал
открывался как обычно — список рассылок 0.5 с, сделки 0.6 с.

Скорость самой отправки не улучшалась — это очередь, а не экран.
2026-07-28 09:38:02 +03:00
Дмитрий 9fc2f030cf feat(смс-клиент): срок «за последние N дней» не больше года
Строка приёмочного листа 2.8. Больше 365 дней задать нельзя, и человек читает
почему: «Больше 365 дней нельзя — возьмите срок покороче».

Правило max:365 стоит в общей проверке полей — значит и на предпросмотре, и на
создании рассылки, мимо экрана его не обойти. Заодно человеческий текст у нижней
границы: стандартный называл поле программистским именем audience_days.

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

Тесты: 2 новых серверных (отказ с человеческим текстом на обеих дорогах +
контрольная граница ровно 365) и 2 на экране. ClientSms 158/158, приём лидов
17/17, фронт 1654 зелёных. Проверено вырезанием четырежды.

Живой прогон: 30 дней — «Уйдёт 5 СМС — 45.00 ₽»; напечатал 400 — сообщение,
смета исчезла, кнопка заперлась, на сервер не ушло ни одного запроса; вернул
365 — смета снова на месте. Прямыми запросами мимо экрана сервер отказывает тем
же текстом и рассылку не создаёт.
2026-07-28 08:42:14 +03:00
Дмитрий a5451bef76 feat(смс-клиент): галочки статусов воронки в рассылке по сделкам
Строки приёмочного листа 2.6 и 2.7. Клиент выбирает, каким сделкам слать:
пять галочек воронки (Новая сделка, Просмотрено, В работе, Сделка,
Не реализовано), по умолчанию отмечены все.

Отбор применяется в ClientSmsAudienceBuilder::fromDeals() — через него идут
и смета предпросмотра, и снимок получателей при запуске, поэтому число на
экране и факт отправки остаются одним списком.

Пустой список = «все статусы», отдельного значения «никому» нет (решение
В-42): экран не даёт снять последнюю галочку и объясняет почему. Новая
колонка audience_statuses (jsonb, NULL = «все») — уже созданные рассылки
поведения не меняют. Права не нужны: колонка наследует права таблицы.

Живой прогон поймал дефект, которого не видели тесты: запрет снять
последнюю галочку не работал вообще — галочка Vuetify правит список на
месте, и обычное наблюдение этого не видело, а тест присваивал новый
список. Лечение: наблюдение вглубь + возврат после отрисовки; тест
переписан на правку списка на месте.

Тесты: 5 новых серверных + 4 на экране, ClientSms 156/156, приём лидов
17/17, фронт 1652 зелёных. Живой прогон: 5 → 4 получателя после снятия
«Не реализовано»; рассылка только со статусом «Сделка» ушла ровно на номер
этой сделки. Журнал схемы: v9.09 (эта работа) и v9.08 — пропущенная запись
о снимке получателей, дописана задним числом.
2026-07-28 08:09:04 +03:00
Дмитрий a86c5f78ec feat(смс-клиент): тестовая сделка не получает СМС ни одной дорогой
Строка приёмочного листа 2.5. Отсев поставлен в обеих точках, откуда
уходят сообщения:

- ClientSmsAudienceBuilder::fromDeals() — через него идут и предпросмотр
  сметы, и снимок получателей при запуске рассылки;
- SendAutoSmsForDealJob — авто-СМС на новый лид аудиторию не строит,
  своей проверки не имел.

Условие написано как «отмечена НЕ тестовой ИЛИ не отмечена вовсе»:
колонка is_test в базе NULLABLE, и наивное «is_test = false» молча
выбрасывало бы сделки с пустым флагом (решение В-43). Доказано
вырезанием: с наивным условием получателей 0 вместо 1.

Тесты: 4 новых (AudienceFilterTest), ClientSms 151/151, приём лидов
17/17. Живой прогон: в предпросмотре 5 → 4 получателя после отметки
одной сделки тестовой; авто-СМС на тестовую сделку не ушло, а после
снятия отметки на той же сделке ушло — ноль не молчаливый сбой.
2026-07-28 07:21:06 +03:00
Дмитрий 3a2f260334 feat(смс-клиент): кончились деньги — рассылка встаёт честно, а не молча
Перед каждым списанием джоб проверяет, что деньги есть. Без этого
AdWalletService::charge при недоборе не бросает исключение, а прижимает
баланс к нулю: часть рассылки уходила бы бесплатно, а клиент видел бы
пустой кошелёк без объяснения.

Доступное этой рассылке = баланс − заморожено + СВОЯ активная заморозка.
Наивное «баланс − заморожено» остановило бы рассылку на первом же номере
при полном кошельке — вся смета заморожена при создании (ловушка В-41,
доказана вырезанием: тест-страховка краснеет).

Кончились деньги — номер пишется в журнал как «не хватило денег», рассылка
получает статус «остановлена» с причиной no_funds, заморозка снимается.
Экран разбирает причину: «Остановлено: закончились деньги. Ушло N из M,
списано X ₽».

Живой прогон нашёл то, чего не видели тесты (В-48): статус в колонке рядом
писал «остановлена вами» и для денежной остановки. Починено, тест расширен
на всю ячейку.

Строка приёмочного листа 2.4. Тесты: ClientSms 147/147, приём лидов 17/17,
фронт 1648, phpstan 0, pint чисто.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 06:36:11 +03:00