Files
portal/app/tests/Feature/Sales/AdAudienceRecalcTest.php
T
2026-07-22 10:46:34 +03:00

261 lines
12 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\Jobs\RecalcAdAudienceJob;
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 Tests\Concerns\SharesSupplierPdo;
/**
* Ночной пересчёт состояний рекламы (Task 5).
*
* RecalcAdAudienceJob смотрит на стадию связанной карточки sales_prospects (если
* она есть) и на даты фирмы, зовёт чистую логику AdAudienceScheduler и проставляет
* каждому номеру фирмы state/resume_at. Ничего не заливает в Яндекс — это
* отдельный SyncAdAudienceJob (Task 6).
*
* Оба трейта обязательны: SalesAdAudienceFirm/Phone живут на pgsql_supplier
* (отдельный PDO), DatabaseTransactions один их не откатывает — мусор остаётся
* в базе и второй прогон падает по уникальному индексу uq_ad_firm_inn/phone.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function firmWithPhone(array $firmAttrs = []): SalesAdAudienceFirm
{
// Номер уникален в таблице — считаем по порядку, а не случайно (иначе тест мигает).
static $seq = 0;
$seq++;
// Прогреваемая фирма всегда стоит хотя бы на одной площадке. По умолчанию —
// Яндекс (его сроки бэкфилл = дефолты синглтона, поэтому решение по одному
// Яндексу совпадает со старым решением «одной фирмы»). Новые multi-platform
// тесты выставляют ch_vk/ch_mts явно.
$firm = SalesAdAudienceFirm::create(array_merge([
'firm_name' => 'Тестовая стоматология',
'warmup_started_at' => now(),
'ch_yandex' => true,
], $firmAttrs));
SalesAdAudiencePhone::create([
'phone' => '7999000'.str_pad((string) $seq, 4, '0', STR_PAD_LEFT),
'firm_id' => $firm->id,
'expires_at' => now()->addDays(30),
'state' => 'active',
]);
return $firm;
}
function recalcHead(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'recalchead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
it('гасит рекламу и помечает фирму готовой, когда прогрев кончился', function () {
$firm = firmWithPhone(['warmup_started_at' => now()->subDays(10)]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->phones()->first()->state)->toBe('stopped')
->and($firm->stop_reason)->toBe('ready_for_manager')
->and($firm->ready_at)->not->toBeNull();
});
it('оставляет рекламу включённой, пока прогрев идёт', function () {
$firm = firmWithPhone(['warmup_started_at' => now()->subDay()]);
RecalcAdAudienceJob::dispatchSync();
expect($firm->phones()->first()->state)->toBe('active');
});
it('гасит рекламу, когда клиент пополнил баланс', function () {
$user = recalcHead();
$prospect = SalesProspect::create([
'sales_user_id' => $user->id,
'firm_name' => 'Тестовая стоматология',
'stage' => 'topped_up',
]);
$firm = firmWithPhone([
'prospect_id' => $prospect->id,
'assigned_at' => now()->subDay(),
]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->phones()->first()->state)->toBe('stopped')
->and($firm->stop_reason)->toBe('client_won');
});
it('ставит на паузу, когда созвон далеко, и записывает дату включения', function () {
$user = recalcHead();
$prospect = SalesProspect::create([
'sales_user_id' => $user->id,
'firm_name' => 'Тестовая стоматология',
'stage' => 'negotiation',
'next_call_at' => now()->addDays(60),
]);
// Фирма уже сама помнит, что видела «переговоры» месяц назад — честно изображает
// карточку, которая сидит в этой стадии давно (а не только что назначена).
$firm = firmWithPhone([
'prospect_id' => $prospect->id,
'assigned_at' => now()->subDays(30),
'stage_seen' => 'negotiation',
'stage_changed_at' => now()->subDays(30),
]);
RecalcAdAudienceJob::dispatchSync();
$phone = $firm->phones()->first();
expect($phone->state)->toBe('paused')
->and($phone->resume_at)->not->toBeNull();
});
it('правка карточки не перезапускает отсчёт рекламы', function () {
// Дыра, которую поймали на прошлой попытке: если брать дату из
// prospect->updated_at, любая правка карточки (например заметка менеджера)
// ложно продлевала бы рекламу. Проверяем, что этого не происходит: фирма
// помнит, что стадия 'in_work' наступила 20 дней назад (лимит in_work_days=14
// давно вышел), а карточка обновлена только что — реклама всё равно гаснет.
$user = recalcHead();
$prospect = SalesProspect::create([
'sales_user_id' => $user->id,
'firm_name' => 'Тестовая стоматология',
'stage' => 'in_work',
]);
// Правка карточки прямо сейчас — updated_at свежий.
$prospect->update(['notes' => 'менеджер поправил заметку']);
$firm = firmWithPhone([
'prospect_id' => $prospect->id,
'assigned_at' => now()->subDays(20),
'stage_seen' => 'in_work',
'stage_changed_at' => now()->subDays(20),
]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->phones()->first()->state)->toBe('stopped')
->and($firm->stop_reason)->toBe('stage_limit');
});
it('читает карточки тем соединением, которое ему передали', function () {
// 🔴 Регресс на боевую поломку 19.07: джоб бежит вне веб-запроса, то есть
// на дефолтной роли crm_app_user, у которой НЕТ прав на sales_prospects.
// Планировщик обязан передавать соединение с правами (на бою pgsql_supplier).
$prospect = SalesProspect::create([
'sales_user_id' => recalcHead()->id,
'firm_name' => 'Тестовая стоматология',
'stage' => 'topped_up',
]);
$firm = firmWithPhone([
'prospect_id' => $prospect->id,
'assigned_at' => now()->subDay(),
'stage_seen' => 'topped_up',
'stage_changed_at' => now()->subDay(),
]);
// Явно передаём соединение — как это делает планировщик на бою.
(new RecalcAdAudienceJob('pgsql_supplier'))->handle();
$firm->refresh();
expect($firm->phones()->first()->state)->toBe('stopped')
->and($firm->stop_reason)->toBe('client_won');
});
it('фирма active хотя бы на одной площадке — сводка active, stopped_at null', function () {
// yandex warmup 5 дней, vk warmup 3 дня; warmup_started 4 дня назад:
// по yandex ещё active, по vk уже погасла → сводка active.
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 5]);
SalesAdAudiencePlatform::forCode('vk')->update(['warmup_days' => 3]);
$firm = firmWithPhone(['warmup_started_at' => now()->subDays(4), 'ch_yandex' => true, 'ch_vk' => true]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->phones()->first()->state)->toBe('active')
->and($firm->stopped_at)->toBeNull();
});
it('погасла на ОБЕИХ площадках — сводка stopped, stopped_at выставлен', function () {
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 5]);
SalesAdAudiencePlatform::forCode('vk')->update(['warmup_days' => 3]);
$firm = firmWithPhone(['warmup_started_at' => now()->subDays(6), 'ch_yandex' => true, 'ch_vk' => true]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->phones()->first()->state)->toBe('stopped')
->and($firm->stopped_at)->not->toBeNull()
->and($firm->stop_reason)->toBe('ready_for_manager') // оба погасли по warmup → ready_for_manager
->and($firm->ready_at)->not->toBeNull();
});
it('две площадки на паузе — сводка paused, resume_at = самый ранний, reason call_far', function () {
// ⚠️ Приоритет reason в сводке (ready_for_manager > client_won > stage_limit/…)
// на практике НЕДОСТИЖИМ для КРЕСТА разных reason: stage/next_call_at/даты общие
// для всех площадок фирмы, различаются только durations. Разные durations могут
// лишь толкнуть площадки через порог ОДНОЙ И ТОЙ ЖЕ стадии → reason выходит
// одинаковый. Поэтому здесь пиним ДОСТИЖИМЫЙ сценарий: обе площадки в переговорах
// с далёким созвоном дают paused(call_far), но с разным resume_at (разный
// negotiation_far_lead_days) → сводка берёт самый ранний resume_at, а reason —
// из непустых (оба call_far). Заодно проверяем spec §3.4: stopped_at
// выставляется, когда фирма НЕ active ни на одной площадке (paused тоже).
SalesAdAudiencePlatform::forCode('vk')->update(['negotiation_far_lead_days' => 10]);
// yandex — дефолтный negotiation_far_lead_days = 3.
$user = recalcHead();
$prospect = SalesProspect::create([
'sales_user_id' => $user->id,
'firm_name' => 'Тестовая стоматология',
'stage' => 'negotiation',
'next_call_at' => now()->addDays(60),
]);
$firm = firmWithPhone([
'prospect_id' => $prospect->id,
'assigned_at' => now()->subDays(30),
'stage_seen' => 'negotiation',
'stage_changed_at' => now()->subDays(30),
'ch_yandex' => true,
'ch_vk' => true,
]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
$phone = $firm->phones()->first();
// yandex resume = call 3 дня; vk resume = call 10 дней ⇒ самый ранний = vk.
$expectedEarliest = now()->addDays(60)->subDays(10);
expect($phone->state)->toBe('paused')
->and($phone->resume_at)->not->toBeNull()
->and($phone->resume_at->toDateString())->toBe($expectedEarliest->toDateString())
->and($firm->stop_reason)->toBe('call_far')
->and($firm->stopped_at)->not->toBeNull();
});