9b66c7cd71f09d950076dc44aced9ddd7a0d60ca
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 больше). Права и изоляция — разные механизмы, эта миграция того шага не заменяет. |
||
|
|
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: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку. |
||
|
|
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.
|
||
|
|
c5943ebb4e |
feat(смс-клиент): снимок получателей — считаем смету и шлём по одному списку
Аудитория собиралась дважды: в контроллере для сметы и заново в джобе для отправки. Между двумя сборками приходят новые лиды, и «посчитали 900, отправили 917» было физически возможно. Теперь контроллер, посчитав смету, кладёт получателей в client_sms_campaign_phones (две новые колонки: operator, skip_reason), а джоб читает этот снимок пачками по 500 и ничего не пересобирает. Решение владельца В-39: после запуска список не пересматривается ничем, включая стоп-листы — смета и факт сходятся копейка в копейку. Человек, внесённый в «Не писать этим» уже после запуска, эту рассылку получит; следующую — нет. Следствие названо и владельцем принято. Попутно убран квадрат: проверка «номер уже отправлен» шла через in_array по массиву — на 20 000 номеров это 400 млн сравнений. Строки приёмочного листа 2.1 и 2.2. Проверено: ClientSms 144/144, приём лидов 17/17, phpstan по своим файлам 0, вырезание записи снимка красит все 4 новых теста, живой прогон — контакт, добавленный между созданием и отправкой, СМС не получил. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b29a4ca4c4 |
revert(смс-клиент): отказ получателя убран целиком — решение владельца
Владелец: «нет такой функции и задачи нет, забудь о ней! пришла и пришла смс».
Причина — приписка «Отказ: liderra.ru/s/…» ставила НАШ адрес в рекламное СМС, которое
клиент шлёт своим покупателям: он рекламирует себя, а не нас.
Убрано: страница отказа /s/{token}, таблица коротких ссылок, сервис токенов, приписка
в тексте рассылки, колонка with_optout_link, ограничение частоты sms-unsubscribe,
три файла тестов, три миграции (на прод не выкатывались).
Осталось нетронутым: стоп-лист самого клиента «Не писать этим» и общий стоп-лист
портала — это другое, их владелец не отменял.
Строки приёмочного листа 1.6-1.13 срезаны, записано в «Чего эта работа НЕ делает» п.14
и в журнал вопросов В-30. Возражение про 38-ФЗ высказано владельцу и им отклонено.
Всё удалённое лежит в истории: коммиты
|
||
|
|
1dece3a2b4 |
feat(смс-клиент): хвост «Отказ: liderra.ru/s/…» в тексте — цена считается уже с ним
Галочка «добавить возможность отказа» в рассылке, по умолчанию включена (спека §9.2). Токен свой у каждого получателя, поэтому текст собирается на каждый номер отдельно в момент отправки. Длина хвоста постоянная — число кусков и цена считаются один раз, при создании рассылки, УЖЕ с хвостом: клиент видит ту длину, за которую заплатит. Заготовка хвоста живёт в одном месте (сервис ссылок отказа), длина берётся из неё же — число в код не вписано, разойтись расчёту и отправке нечем. Предпросмотр отдаёт второе число (сколько кусков было бы без хвоста), чтобы экран мог сказать прямо: хвост перевёл текст на второй кусок. Строки приёмочного листа 1.10-1.13 (серверная часть). Защита проверена вырезанием: считать цену без хвоста — 2 красных теста, не дописывать хвост при отправке — 1. ClientSms 146/146, приём лидов 17/17, phpstan по своим файлам чисто. |
||
|
|
c47c3fb4d5 |
fix(смс-клиент): недосписание при повторе, срок для рассылки по сделкам, заслонка баланса + понятный интерфейс
Корректность/деньги: - джоб рассылки: списание ПОШТУЧНО и атомарно с записью в журнал, ключ идемпотентности на номер, факт из БД — нет недосписания при падении посреди рассылки и повторе - валидация: срок обязателен при source=deals, иначе выборка по всей истории сделок - «Отправить» гаснет при нехватке свободного баланса; index отдаёт баланс/заморозку - помесячный джоб имени: устойчив к пустым настройкам, сигнал в лог при автоотключении за долг - цена одного авто-СМС + подтверждение при включении авто-рассылки - загрузка контактов сообщает число нераспознанных номеров Понятность для клиента: - «СМС» вместо «сегментов», «пробный режим» вместо «песочницы», человеческие причины пропуска, шаги 1-2-3, выгода своего имени, факт вместо оценки в таблице, имя и текст в подтверждении Тесты: бэкенд ClientSms 83/83, фронт СМС 50/50, приём лидов 10/10; pint/stan чисто. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5d423be226 |
feat(смс-клиент): джоб отправки — деньги через кошелёк, песочница, retry
SendClientSmsCampaignJob: single-tenant под crm_app_user (SET LOCAL перед каждой операцией; сеть вне транзакций). Списание/снятие заморозки через AdWalletService (канал sms, рубли, идемпотентно по external_key). Песочница не двигает деньги. failed() снимает заморозку. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |