3ec722416c
ClientSmsPricing (ступень по объёму получатели×сегменты, наценка не применяется), ClientSmsAudienceBuilder (сделки/база/список, tenant-scope SET LOCAL + явный where, нормализация телефона), ClientSmsRecipientSelector (стоп-лист→дубль→маршрут), ClientSmsPlan DTO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
6.4 KiB
PHP
197 lines
6.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);
|
||
|
||
expect($result)->toBe([
|
||
['phone' => '79990000001', 'operator' => 'МТС'],
|
||
]);
|
||
});
|
||
|
||
it('builds audience from own contact base (source=base)', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
ClientSmsContact::create([
|
||
'tenant_id' => $tenant->id,
|
||
'phone' => '79990000011',
|
||
'name' => 'Иван',
|
||
'operator' => 'Билайн',
|
||
]);
|
||
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' => 'Билайн'],
|
||
['phone' => '79990000012', 'operator' => 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],
|
||
['phone' => '79990000022', 'operator' => 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' => 'МТС'],
|
||
])
|
||
->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' => 'МегаФон',
|
||
'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' => 'МегаФон'],
|
||
]);
|
||
});
|