Files
portal/app/tests/Feature/ClientSms/AudienceBuilderTest.php
T
Дмитрий 1d8723f285 feat(смс-клиент): Калининград и Камчатка получают СМС каждый в своё утро
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.

У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.

Три состояния строки, и их важно не путать:
  — пояс известен, ждать нечего  → отдаём прямо сейчас;
  — пояс известен, время не пришло → ждёт своего утра;
  — пояса нет                     → ждёт уточнения региона и НЕ уходит вовсе (В-85).

Пустой пояс больше не означает «шлём по Москве». Он означает «мы не знаем, где человек
живёт», и такому номеру СМС не уходит. Про него при этом НЕ пишется в журнал рассылки
«нет маршрута»: мы его даже не пробовали отправить, и врать про него нельзя.

Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).

Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).

Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.

В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.

Запись схемы v9.13.
2026-07-28 15:46:53 +03:00

206 lines
7.4 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\Models\ClientSmsCampaign;
use App\Models\ClientSmsCampaignPhone;
use App\Models\ClientSmsContact;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\ClientSms\ClientSmsAudienceBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makeClientSmsCampaign(int $tenantId, string $source, ?int $audienceDays = null): ClientSmsCampaign
{
return ClientSmsCampaign::create([
'tenant_id' => $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('builds audience from manual campaign phone list honoring expiry (source=manual)', function () {
$tenant = Tenant::factory()->create();
$campaign = makeClientSmsCampaign($tenant->id, ClientSmsCampaign::SOURCE_MANUAL);
ClientSmsCampaignPhone::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000021',
'expires_at' => null,
]);
ClientSmsCampaignPhone::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000022',
'expires_at' => now()->addDays(3),
]);
// истёкший — не должен попасть
ClientSmsCampaignPhone::create([
'tenant_id' => $tenant->id,
'campaign_id' => $campaign->id,
'phone' => '79990000023',
'expires_at' => now()->subDay(),
]);
$result = app(ClientSmsAudienceBuilder::class)->build($campaign);
// Про вписанный руками номер не известно ничего — ни оператора, ни региона.
expect($result)->toEqualCanonicalizing([
['phone' => '79990000021', 'operator' => null, 'tz' => null],
['phone' => '79990000022', 'operator' => null, 'tz' => null],
]);
});
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],
]);
});