1d8723f285
Этап 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.
206 lines
7.4 KiB
PHP
206 lines
7.4 KiB
PHP
<?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],
|
||
]);
|
||
});
|