c516e73255
Найдено при чтении кода перед Task 11, вычищено по указанию владельца «не оставляй хвостов» (журнал В-183). Что было. Ветка `ClientSmsAudienceBuilder::fromManual` читала снимок получателей и отсеивала строки по `expires_at`. При этом: · колонку `expires_at` не заполняет НИКТО — ни контроллер, ни джобы, ни обогащение ДаДатой; на стенде она пуста во всех строках; · саму ветку не зовёт НИКТО: номера, вписанные руками, контроллер берёт прямо из запроса и разбирает единственным домом (`normalizeManualPhones`, строка листа 4.8). То есть код обещал «у номера в списке есть срок годности» — поведение, которого в портале нет. Опасность не теоретическая: я сам, начиная Task 11, чуть не стал считать возраст снимка по этой колонке. Что стало. Ветка убрана, на её месте ГРОМКИЙ отказ с объяснением, а не пустой массив: пустая аудитория читалась бы как «рассылка честно ушла на ноль номеров» — тот самый молчаливый сбой (В-121). Тест переписан на новое поведение, мёртвый импорт убран. 🔴 Саму колонку `expires_at` НЕ удалял, и это осознанно: удаление необратимо, а что лежит в ней на БОЕВОЙ базе, проверить нельзя — она по умолчанию только для чтения и только с разрешения владельца. Оставлено названным хвостом в приёмочном листе (п.30 «Чего эта работа НЕ делает»): удаление — отдельная миграция со своим доказательством. Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
7.5 KiB
PHP
193 lines
7.5 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('источник «список руками» этот сборщик не строит и говорит об этом ГРОМКО, а не пустым списком', 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],
|
||
]);
|
||
});
|