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(); });