97a93cb763
Строка листа 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>
257 lines
13 KiB
PHP
257 lines
13 KiB
PHP
<?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();
|
||
});
|