fdbff6e2d40e36c5abf97dfe7bbde69c05ee967c
8 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> |
||
|
|
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: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку. |
||
|
|
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 не закончен, цена считается по-старому. |
||
|
|
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). |
||
|
|
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> |
||
|
|
51b8f501ae |
feat(смс-клиент): бэкенд имени и авто-рассылки
Жизненный цикл имени: клиент requestSender (freeze первого месяца) / disableSender; админ approve (charge+release, active, paid_until+1мес) / reject / disable. Помесячная оплата ChargeSmsNameFeeJob (проверка средств ДО списания, долг>29 дней→suspended, идемпотентно по external_key), расписание в console.php. Авто-рассылка: DealSmsObserver (защитный, freshness-guard, НИКОГДА не роняет приём лида) → SendAutoSmsForDealJob (best-effort, идемпотентно по deal_id, песочница/деньги/маршрут). Действующее имя кампании = active-имя тенанта иначе liderra.ru. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |