Files
portal/app/tests/Feature/ClientSms/ResumeCampaignTest.php
T
Дмитрий 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

257 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\SendClientSmsCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientSmsCampaign;
use App\Models\ClientSmsContact;
use App\Models\ClientSmsMessage;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientSms\AllowedSmsOperators;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use App\Services\ClientSms\ClientSmsPricing;
use App\Services\ClientSms\ClientSmsRecipientSelector;
use App\Services\ClientSms\ClientSmsSnapshotReader;
use App\Services\ClientSms\ClientSmsSnapshotWriter;
use App\Services\Sms\OperatorNormalizer;
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsRouter;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* Строка листа 4.13: кнопка «Продолжить рассылку» (решение владельца В-49 «надо обязательно»).
*
* Рассылка встала из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт
* дальше С ТОГО МЕСТА: уже отправленным повторно не шлём, а тому, на кого денег не хватило,
* шлём обязательно.
*
* 🔴 Мина, ради которой этот тест главный (В-151, подтверждена чтением кода): джоб отправки
* считает обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, БЕЗ
* разбора статуса. А номер, на котором кончились деньги, записан как `skipped_no_funds`.
* Значит простой повторный запуск молча пропустил бы его: человек заплатил бы за продолжение,
* а получатель не получил бы ничего. Поэтому продолжение эти строки УДАЛЯЕТ (В-184 — иначе
* не даёт уникальный ключ по клиенту+рассылке+номеру).
*
* Обстановка не изобретена: она взята из `NoFundsStopTest` (там рассылка доводится ровно до
* состояния «встала из-за денег»), помощники переименованы с префиксом resume* — имена
* функций в Pest ГЛОБАЛЬНЫЕ.
*
* Номера только синтетические 7999… — реальные НИКОГДА.
*/
uses(RefreshDatabase::class);
beforeEach(function () {
// Окно 10–20 по местному времени: 09:00 UTC = полдень в Москве, окно открыто.
$this->travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC'));
});
/** Кампания со снимком цены 8.50 за СМС — как её создаёт контроллер. */
function resumeCampaign(int $tenantId, int $totalSms): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $tenantId,
'title' => 'Рассылка',
'body' => 'Здравствуйте!',
'sender_name' => 'liderra.ru',
'source' => ClientSmsCampaign::SOURCE_BASE,
'status' => ClientSmsCampaign::STATUS_QUEUED,
'segments' => 1,
'planned_count' => $totalSms,
'sent_count' => 0,
'total_sms' => $totalSms,
'price_rub_per_sms' => '8.50',
'estimated_cost_rub' => bcmul('8.50', (string) $totalSms, 2),
'actual_cost_rub' => null,
]);
}
/** 🪤 В-46: канал выбирается ДВАЖДЫ — подменять надо роутер в контейнере. */
function resumeBindRoute(): void
{
app()->instance(SmsRouter::class, new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])]));
}
function resumeSnapshot(ClientSmsCampaign $campaign): void
{
$selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer));
$recipients = app(ClientSmsAudienceBuilder::class)->build($campaign);
app(ClientSmsSnapshotWriter::class)->write(
(int) $campaign->tenant_id,
(int) $campaign->id,
$selector->build($recipients, (int) $campaign->tenant_id),
);
}
function resumeRunJob(int $campaignId, int $tenantId, AdWalletService $wallet): void
{
(new SendClientSmsCampaignJob($campaignId, $tenantId))->handle(
app(ClientSmsSnapshotReader::class),
app(ClientSmsPricing::class),
$wallet,
);
}
/**
* Доводит рассылку на 3 номера до состояния «встала из-за денег»: одно СМС ушло,
* на втором деньги кончились. Возвращает клиента, рассылку и кошелёк.
*/
function resumeStuckByMoney(): array
{
config(['services.sms.sandbox' => false]);
Http::fake(['api.mts.ru/*' => Http::response([
'code' => 0,
'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK']]],
], 200)]);
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '1000.00', null, 'test');
foreach (['79995550001', '79995550002', '79995550003'] as $i => $phone) {
ClientSmsContact::create([
'tenant_id' => $tenant->id,
'phone' => $phone,
'name' => 'К'.$i,
'operator' => 'МТС',
'tz_offset_minutes' => 180,
]);
}
resumeBindRoute();
$campaign = resumeCampaign($tenant->id, 3);
resumeSnapshot($campaign);
$wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '25.50');
// Осталось 12.00 — хватает ровно на одно СМС, на второе уже нет.
DB::table('ad_wallets')->where('tenant_id', $tenant->id)->update(['balance_rub' => '12.00']);
resumeRunJob($campaign->id, $tenant->id, $wallet);
return [$tenant, $campaign->fresh(), $wallet];
}
/** Клиентский пользователь для запросов к адресам кабинета. */
function resumeActor(int $tenantId): User
{
return User::factory()->create(['tenant_id' => $tenantId]);
}
it('4.13 — продолжение шлёт тому, на ком кончились деньги, и не шлёт повторно уже отправленным', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
// Убеждаемся, что обстановка та: встала по деньгам, ушло одно, один номер с отказом.
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS)
->and($campaign->sent_count)->toBe(1)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
// Клиент пополнил кошелёк и нажал «Продолжить».
$wallet->topup($tenant->id, '100.00', null, 'пополнение перед продолжением');
$this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertOk();
resumeRunJob($campaign->id, $tenant->id, $wallet);
$campaign->refresh();
// Ушло всем трём, и у КАЖДОГО номера ровно одна запись в журнале.
expect($campaign->sent_count)->toBe(3)
->and($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)->count())->toBe(3)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(0)
->and((string) $campaign->actual_cost_rub)->toBe('25.50');
});
it('4.13 — рассылку, остановленную клиентом, продолжить нельзя', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
// Та же рассылка, но причина остановки — решение человека.
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_CLIENT]);
$response = $this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertStatus(422);
// Текст объясняет, ЧЬЁ это было решение: человеку важно понимать, что остановил он сам.
expect($response->json('message'))->toContain('остановили сами');
// Ничего не изменилось: ни статус, ни журнал.
expect(ClientSmsCampaign::whereKey($campaign->id)->value('stop_reason'))->toBe(ClientSmsCampaign::STOP_CLIENT)
->and(ClientSmsCampaign::whereKey($campaign->id)->value('status'))->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
});
it('4.13 — рассылку, сорванную сторожем, продолжить нельзя', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_WATCHDOG]);
$response = $this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertStatus(422);
// Здесь причина другая, и текст обязан быть другим: остановила Лидерра из-за сбоя.
expect($response->json('message'))->toContain('из-за сбоя');
expect(ClientSmsCampaign::whereKey($campaign->id)->value('status'))->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1);
});
it('4.13 — при пустом кошельке продолжение не трогает ни рассылку, ни деньги', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
// Денег нет вовсе: продолжать нечем, и врать про это нельзя.
DB::table('ad_wallets')->where('tenant_id', $tenant->id)->update(['balance_rub' => '0.00']);
$movesBefore = DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count();
$response = $this->actingAs(resumeActor($tenant->id))
->postJson("/api/sms/campaigns/{$campaign->id}/resume")
->assertStatus(422);
// Отказ называет ДЕНЬГИ, а не «нельзя продолжить».
expect($response->json('message'))->toContain('₽');
$campaign->refresh();
expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED)
->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS)
// Строку «не хватило денег» не тронули: продолжения не было.
->and(ClientSmsMessage::where('campaign_id', $campaign->id)
->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1)
->and(DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count())->toBe($movesBefore)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('0.00');
});
it('4.13 — список рассылок сам говорит, какую можно продолжить и сколько осталось', function () {
[$tenant, $campaign, $wallet] = resumeStuckByMoney();
$rows = $this->actingAs(resumeActor($tenant->id))
->getJson('/api/sms/campaigns')
->assertOk()
->json('campaigns');
$row = collect($rows)->firstWhere('id', $campaign->id);
// Правило живёт на СЕРВЕРЕ (урок В-146): экран не разбирает причины сам.
// Осталось двое: тот, на ком встали, и тот, до кого не дошли.
expect($row['can_resume'])->toBeTrue()
->and($row['resume_pending_count'])->toBe(2);
ClientSmsCampaign::whereKey($campaign->id)->update(['stop_reason' => ClientSmsCampaign::STOP_CLIENT]);
$rows = $this->actingAs(resumeActor($tenant->id))->getJson('/api/sms/campaigns')->json('campaigns');
expect(collect($rows)->firstWhere('id', $campaign->id)['can_resume'])->toBeFalse();
});