$tenantId, 'title' => 'Т', 'body' => 'Здравствуйте!', 'sender_name' => 'LIDERRA', 'source' => $source, 'audience_days' => $audienceDays, 'status' => ClientSmsCampaign::STATUS_DRAFT, 'segments' => 1, 'planned_count' => 0, 'sent_count' => 0, 'total_sms' => 0, 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => '0.00', 'actual_cost_rub' => null, 'created_by' => null, ]); } it('builds audience from deals honoring window, soft-delete and duplicate master', function () { $tenant = Tenant::factory()->create(); // в окне, мастер-лид — должен попасть Deal::factory()->create([ 'tenant_id' => $tenant->id, 'phone' => '79990000001', 'phone_operator' => 'МТС', 'received_at' => now()->subDays(2), ]); // вне окна — не должен попасть Deal::factory()->create([ 'tenant_id' => $tenant->id, 'phone' => '79990000002', 'phone_operator' => 'МТС', 'received_at' => now()->subDays(30), ]); // мягко удалён — не должен попасть $deleted = Deal::factory()->create([ 'tenant_id' => $tenant->id, 'phone' => '79990000003', 'phone_operator' => 'МТС', 'received_at' => now()->subDays(1), ]); $deleted->delete(); // дубликат другого лида (duplicate_of_id не null) — не должен попасть Deal::factory()->create([ 'tenant_id' => $tenant->id, 'phone' => '79990000004', 'phone_operator' => 'МТС', 'duplicate_of_id' => 999999, 'received_at' => now()->subDays(1), ]); $campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_DEALS, 5); $result = app(ClientSmsAudienceBuilder::class)->build($campaign); // Этап 3: вместе с номером аудитория отдаёт его часовой пояс. У этой сделки региона // нет, поэтому пусто — «не знаем, где человек живёт» (В-85). expect($result)->toBe([ ['phone' => '79990000001', 'operator' => 'МТС', 'tz' => null], ]); }); it('builds audience from own contact base (source=base)', function () { $tenant = Tenant::factory()->create(); ClientSmsContact::create([ 'tenant_id' => $tenant->id, 'phone' => '79990000011', 'name' => 'Иван', 'operator' => 'Билайн', // Этап 3: пояс своей базы проставляет ДаДата; здесь он уже известен. 'tz_offset_minutes' => 180, ]); ClientSmsContact::create([ 'tenant_id' => $tenant->id, 'phone' => '79990000012', 'name' => 'Пётр', 'operator' => null, ]); $campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_BASE); $result = app(ClientSmsAudienceBuilder::class)->build($campaign); expect($result)->toEqualCanonicalizing([ ['phone' => '79990000011', 'operator' => 'Билайн', 'tz' => 180], ['phone' => '79990000012', 'operator' => null, 'tz' => null], ]); }); it('источник «список руками» этот сборщик не строит и говорит об этом ГРОМКО, а не пустым списком', function () { // Раньше здесь была ветка, читавшая снимок и отсеивавшая строки по `expires_at` — // колонке, в которую не пишет никто. Вызовов у неё не было ни одного (проверено при // Task 11): номера, вписанные руками, контроллер берёт прямо из запроса и разбирает // сам (`normalizeManualPhones`, строка листа 4.8). Ветку убрал, но поставил громкий // отказ вместо пустого массива: пустая аудитория читалась бы как «рассылка честно // ушла на ноль номеров» — тот самый молчаливый сбой (В-121). $tenant = Tenant::factory()->create(); $campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_MANUAL); ClientSmsCampaignPhone::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000021', ]); expect(fn () => app(ClientSmsAudienceBuilder::class)->build($campaign)) ->toThrow(LogicException::class); }); it('never leaks phones across tenants', function () { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); Deal::factory()->create([ 'tenant_id' => $tenantA->id, 'phone' => '79990000031', 'phone_operator' => 'МТС', 'received_at' => now()->subDays(1), ]); Deal::factory()->create([ 'tenant_id' => $tenantB->id, 'phone' => '79990000032', 'phone_operator' => 'МТС', 'received_at' => now()->subDays(1), ]); ClientSmsContact::create([ 'tenant_id' => $tenantB->id, 'phone' => '79990000033', 'name' => null, 'operator' => null, ]); $campaignDeals = makeClientSmsCampaign($tenantA->id, ClientSmsCampaign::SOURCE_DEALS, 5); $campaignBase = makeClientSmsCampaign($tenantA->id, ClientSmsCampaign::SOURCE_BASE); $resultDeals = app(ClientSmsAudienceBuilder::class)->build($campaignDeals); $resultBase = app(ClientSmsAudienceBuilder::class)->build($campaignBase); expect($resultDeals)->toBe([ ['phone' => '79990000031', 'operator' => 'МТС', 'tz' => null], ]) ->and($resultBase)->toBe([]); }); it('dedups phones across formats preferring a non-null operator, normalized to bare 11 digits', function () { $tenant = Tenant::factory()->create(); // тот же телефон в разных форматах ввода, разные сделки Deal::factory()->create([ 'tenant_id' => $tenant->id, 'phone' => '89991112233', 'phone_operator' => null, // У этой сделки региона нет… 'received_at' => now()->subDays(1), ]); Deal::factory()->create([ 'tenant_id' => $tenant->id, 'phone' => '+79991112233', 'phone_operator' => 'МегаФон', // …а у этой есть: 27 = Камчатский край, UTC+12. 'subject_code' => 27, 'received_at' => now()->subDays(2), ]); $campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_DEALS, 5); $result = app(ClientSmsAudienceBuilder::class)->build($campaign); // При схлопывании дублей знание побеждает незнание — и про оператора, и про пояс. expect($result)->toBe([ ['phone' => '79991112233', 'operator' => 'МегаФон', 'tz' => 720], ]); });