Правка целиком в комментариях: 37 файлов, ни строки кода.
Её дописал сам pre-commit форматтер во время предыдущей записи и оставил
неподтверждённой. Записываю, чтобы папка ветки не оставалась грязной.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.
Девять столкновений, каждое разобрано по существу.
Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.
Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.
Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.
Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.
Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.
СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.
Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
- 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
перенесены из рабочей ветки, где владелец их уже принял;
- остальные 42 - свои, в новом коде ветки, и починены по существу:
задвоенный ключ массива в трёх тестах (след копирования - комментарий
оторвался от своей строки), врущие описания двух помощников (PHP сам
делает из ключа-номера число), сужение типа возврата, прятавшее от
анализатора свойства подставного отправителя, лишний знак вопроса и
четыре бесполезных перенумерования списка.
- Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
поимённо и покраснел; убрал - ноль.
Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.
Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.
Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.
Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.
NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Строка листа 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>
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или
виртуального оператора в рассылку не берётся, и человек видит честную причину,
а не молчаливую пропажу.
Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный
канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём.
Список — настройкой, а не в коде: client_sms_settings.allowed_operators
(схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию.
Правило живёт в одном месте — AllowedSmsOperators.
Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок
и своей базы оператор известен в момент заказа, у номеров, вписанных руками,
его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится
уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали».
Плата за имя не тронута (В-150): в коде два похожих списка операторов, и
связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей.
Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём
только по МТС)» — универсальный канал возит всех.
Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка
24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент
ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн».
ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние,
vue-tsc 5 чужих давних, pint чисто.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Строки листа 4.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 чисто. Фронт не трогался.
Этап 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: два моих же новых теста сперва зеленели по неверной причине — ругань
приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо
и проверяют, за какое поле ругаются.
8 таблиц client_sms_* (6 tenant-RLS + 2 глобальных тарифы/настройки),
политики tenant_isolation по образцу ad_wallets, гранты crm_app_user /
crm_admin_user (гварды ролей). Модели ядра + сид ступеней и настроек.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>