Files
portal/app/database
Дмитрий 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
..