Files
portal/app/tests/Feature/Sales/AdAudienceWarmingFieldsTest.php
T
Дмитрий 48b88737e7 feat(прогрев ф2 этап D): backend СМС-канала — свой список sms-firms + счётчик «Отправлено N» (+ план этапа D)
Фаза 2 Этап D, Task D1. Новый эндпоинт GET /api/sales/ad-audience/sms-firms
(только head) отдаёт фирмы со строкой firm_channels(channel='sms'). Хелпер
smsSentCounts считает по каждой фирме число боевых СМС (sales_sms_messages
status='sent') по её номерам; fake_sent (песочница) не считается. firmRow получил
поле sms_sent_count (0 если не слали), проброшено и в firms/allFirms. СМС строкой
всегда loaded, сроками не греется — только чтение sales_sms_messages для счётчика,
тарификация/провайдер/запись кампаний не тронуты. Bump baseline (actingAs-ложняк).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:28:43 +03:00

387 lines
16 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\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudiencePlatform;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
/**
* Task 1 плана «кусок B»: firmRow() отдаёт нишу, дату запуска прогрева,
* менеджера (id + имя) и «состояние прогрева» (активен ли прогрев + список
* каналов, на которые фирма реально подписана) — для витрины на вкладках
* площадок. Аутентификация и создание сущностей — как в AdAudiencePlatformApiTest.
*
* SharesSupplierPdo обязателен: SalesAdAudienceFirm/Phone прибиты к
* pgsql_supplier (свой PDO), без шаринга DatabaseTransactions откатывал бы
* только default-соединение (SalesProspect/SalesUser) — записи текли бы
* из теста в тест.
*
* План: docs/superpowers/plans/... (кусок B, Task 1).
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function warmingFieldsHead(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'wfhead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function warmingFieldsManager(string $name): SalesUser
{
return SalesUser::create([
'name' => $name,
'email' => 'wfmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
]);
}
it('firmRow отдаёт нишу, дату запуска, менеджера и состояние прогрева', function () {
$head = warmingFieldsHead();
$manager = warmingFieldsManager('Таня Михалева');
$prospect = SalesProspect::create([
'sales_user_id' => $manager->id,
'assigned_by' => $head->id,
'stage' => 'new',
'source' => 'search',
'firm_name' => 'Гарант',
'city' => 'Екатеринбург',
]);
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6678048177',
'firm_name' => 'Гарант',
'city' => 'Екатеринбург',
'rubric' => 'Частные стоматологии',
'warmup_started_at' => now()->subDays(4),
'ch_yandex' => true,
'ch_vk' => false,
'ch_mts' => true,
'prospect_id' => $prospect->id,
'assigned_at' => now(),
]);
// Task 8: warming_active/state считаются по строке канала firm_channels(yandex, warming).
// Task C1: warming_channels-бейджи тоже по строкам firm_channels, а не по ch_*.
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1,
]);
$firm->firmChannels()->create([
'channel' => 'mts', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 0,
]);
SalesAdAudiencePhone::create([
'phone' => '79000000001',
'firm_id' => $firm->id,
'firm_inn' => '6678048177',
'state' => 'active',
'expires_at' => now()->addDays(10),
'added_at' => now(),
]);
$row = $this->actingAs($head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->json('firms.0');
expect($row['rubric'])->toBe('Частные стоматологии');
expect($row['warmup_started_at'])->not->toBeNull();
expect($row['manager_id'])->toBe($manager->id);
expect($row['manager_name'])->toBe('Таня Михалева');
expect($row['warming_active'])->toBeTrue();
expect($row['warming_channels'])->toContain('yandex');
expect($row['warming_channels'])->toContain('mts');
expect($row['warming_channels'])->not->toContain('vk');
});
it('warming_channels строит бейджи из строк firm_channels, а не из ch_*', function () {
$head = warmingFieldsHead();
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'По строкам', 'warmup_started_at' => now()->subDay(),
'ch_yandex' => false, 'ch_vk' => false, 'ch_mts' => false,
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$firm->firmChannels()->create([
'channel' => 'mts', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
$row = $this->actingAs($head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->json('firms.0');
expect($row['warming_channels'])->toContain('yandex')
->and($row['warming_channels'])->toContain('mts')
->and($row['warming_channels'])->not->toContain('vk');
});
it('назначает менеджера платформо-независимым маршрутом ad-audience', function () {
$head = warmingFieldsHead();
$manager = warmingFieldsManager('Игорь Соколов');
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6679177538',
'firm_name' => 'Винлайт',
'warmup_started_at' => now()->subDay(),
]);
$this->actingAs($head, 'sales')
->postJson("/api/sales/ad-audience/firms/{$firm->id}/assign", ['sales_user_id' => $manager->id])
->assertOk()
->assertJsonStructure(['prospect_id']);
$firm->refresh();
expect($firm->prospect_id)->not->toBeNull();
$this->actingAs($head, 'sales')
->postJson("/api/sales/ad-audience/firms/{$firm->id}/assign", ['sales_user_id' => $manager->id])
->assertStatus(422);
});
it('ставит канал sms, если номер фирмы попал в отправленную рассылку', function () {
$head = warmingFieldsHead();
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6673240656',
'firm_name' => 'Новодент',
'warmup_started_at' => now()->subDay(),
'ch_yandex' => true,
]);
// Task C1: фирма попадает в firms(yandex) только при наличии строки канала yandex.
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
SalesAdAudiencePhone::create([
'phone' => '79220216061',
'firm_id' => $firm->id,
'firm_inn' => '6673240656',
'state' => 'active',
'expires_at' => now()->addDays(10),
'added_at' => now(),
]);
$campaignId = DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([
'title' => 'т', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id,
'created_at' => now(), 'updated_at' => now(),
]);
DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([
'campaign_id' => $campaignId, 'phone' => '79220216061', 'status' => 'sent',
]);
$row = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/yandex/firms')->json('firms.0');
expect($row['warming_channels'])->toContain('sms');
});
it('не ставит sms в песочнице (fake_sent)', function () {
$head = warmingFieldsHead();
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6660000000', 'firm_name' => 'Песочница',
'warmup_started_at' => now()->subDay(), 'ch_yandex' => true,
]);
// Task C1: строка канала yandex нужна, чтобы фирма вошла в firms(yandex).
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
SalesAdAudiencePhone::create([
'phone' => '79000000099', 'firm_id' => $firm->id, 'firm_inn' => '6660000000',
'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(),
]);
$cid = DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([
'title' => 'т', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id,
'created_at' => now(), 'updated_at' => now(),
]);
DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([
'campaign_id' => $cid, 'phone' => '79000000099', 'status' => 'fake_sent',
]);
$row = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/yandex/firms')->json('firms.0');
expect($row['warming_channels'])->not->toContain('sms');
});
it('firmRow отдаёт channel_status строки канала: loaded vs warming', function () {
$head = warmingFieldsHead();
// Загружена, ждёт запуска (loaded).
$loaded = SalesAdAudienceFirm::create([
'firm_name' => 'Ждёт запуска', 'warmup_started_at' => now()->subDay(),
]);
$loaded->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 0,
]);
$loadedRow = $this->actingAs($head, 'sales')
->getJson('/api/sales/warming/yandex/firms')->assertOk()->json('firms.0');
expect($loadedRow['channel_status'])->toBe('loaded');
// Другая фирма, гретая (warming).
$warming = SalesAdAudienceFirm::create([
'firm_name' => 'Греется', 'warmup_started_at' => now()->subDay(),
]);
$warming->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
$warmingRow = $this->actingAs($head, 'sales')
->getJson('/api/sales/warming/vk/firms')->assertOk()->json('firms.0');
expect($warmingRow['channel_status'])->toBe('warming');
});
it('состояние строки считается по срокам ЗАПРОШЕННОЙ площадки', function () {
$head = warmingFieldsHead();
// Одна фирма на yandex+vk, прогрев начат 4 дня назад.
// yandex warmup 5 → ещё active; vk warmup 3 → уже погасла.
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 5]);
SalesAdAudiencePlatform::forCode('vk')->update(['warmup_days' => 3]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'На обеих', 'warmup_started_at' => now()->subDays(4),
'ch_yandex' => true, 'ch_vk' => true,
]);
// Task 8: строки каналов — точка отсчёта заезда та же, что и warmup_started_at фирмы.
foreach (['yandex', 'vk'] as $ch) {
$firm->firmChannels()->create([
'channel' => $ch, 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at, 'warmed_times' => 1,
]);
}
SalesAdAudiencePhone::create([
'phone' => '79000000055', 'firm_id' => $firm->id,
'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(),
]);
$yandexRow = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/yandex/firms')->json('firms.0');
$vkRow = $this->actingAs($head, 'sales')->getJson('/api/sales/warming/vk/firms')->json('firms.0');
expect($yandexRow['state'])->toBe('active')
->and($yandexRow['warming_active'])->toBeTrue();
expect($vkRow['state'])->toBe('stopped')
->and($vkRow['warming_active'])->toBeFalse();
});
// Task D1 (Фаза 2 Этап D): эндпоинт /ad-audience/sms-firms — фирмы со строкой
// канала sms (СМС не греется сроками, всегда status='loaded'), плюс поле
// sms_sent_count (число боевых СМС status='sent' по номерам фирмы) — везде,
// где отдаётся firmRow().
it('sms-firms отдаёт только фирмы со строкой канала sms', function () {
$head = warmingFieldsHead();
$withSms = SalesAdAudienceFirm::create([
'firm_inn' => '6670000001', 'firm_name' => 'Со смс',
'warmup_started_at' => now()->subDay(),
]);
$withSms->firmChannels()->create([
'channel' => 'sms', 'status' => 'loaded', 'warmed_times' => 0,
]);
$withoutSms = SalesAdAudienceFirm::create([
'firm_inn' => '6670000002', 'firm_name' => 'Без смс',
'warmup_started_at' => now()->subDay(), 'ch_yandex' => true,
]);
$withoutSms->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'warmed_times' => 0,
]);
$rows = $this->actingAs($head, 'sales')
->getJson('/api/sales/ad-audience/sms-firms')
->assertOk()
->json('firms');
$ids = array_column($rows, 'id');
expect($ids)->toContain($withSms->id);
expect($ids)->not->toContain($withoutSms->id);
});
it('sms-firms отдаёт sms_sent_count — число боевых СМС по номеру фирмы', function () {
$head = warmingFieldsHead();
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6670000003', 'firm_name' => 'Считаем смс',
'warmup_started_at' => now()->subDay(),
]);
$firm->firmChannels()->create([
'channel' => 'sms', 'status' => 'loaded', 'warmed_times' => 0,
]);
SalesAdAudiencePhone::create([
'phone' => '79110000003', 'firm_id' => $firm->id, 'firm_inn' => '6670000003',
'state' => 'active', 'expires_at' => now()->addDays(10), 'added_at' => now(),
]);
// Unique-ограничение (campaign_id, phone) — два sent через РАЗНЫЕ рассылки,
// третий (fake_sent, песочница) в счёт не идёт.
$campaignIds = [
DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([
'title' => 'т1', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id,
'created_at' => now(), 'updated_at' => now(),
]),
DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([
'title' => 'т2', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id,
'created_at' => now(), 'updated_at' => now(),
]),
DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([
'title' => 'т3', 'body' => 'т', 'status' => 'done', 'created_by' => $head->id,
'created_at' => now(), 'updated_at' => now(),
]),
];
DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([
['campaign_id' => $campaignIds[0], 'phone' => '79110000003', 'status' => 'sent'],
['campaign_id' => $campaignIds[1], 'phone' => '79110000003', 'status' => 'sent'],
['campaign_id' => $campaignIds[2], 'phone' => '79110000003', 'status' => 'fake_sent'],
]);
$row = $this->actingAs($head, 'sales')
->getJson('/api/sales/ad-audience/sms-firms')
->assertOk()
->json('firms.0');
expect($row['sms_sent_count'])->toBe(2);
});
it('sms-firms доступен только начальнику', function () {
$manager = warmingFieldsManager('Обычный менеджер');
$this->actingAs($manager, 'sales')
->getJson('/api/sales/ad-audience/sms-firms')
->assertStatus(403);
});
it('firmRow отдаёт sms_sent_count=0, если СМС не слали', function () {
$head = warmingFieldsHead();
$firm = SalesAdAudienceFirm::create([
'firm_name' => 'Без счёта смс', 'warmup_started_at' => now()->subDay(), 'ch_yandex' => true,
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'warmed_times' => 0,
]);
$row = $this->actingAs($head, 'sales')
->getJson('/api/sales/warming/yandex/firms')
->assertOk()
->json('firms.0');
expect($row['sms_sent_count'])->toBe(0);
});