Files
portal/app/tests/Feature/Sales/AdAudienceRecalcTest.php
T
Дмитрий 475d814100 feat(прогрев ф2): recalc бежит по строкам firm_channels — гасит выдохшихся в loaded
Фаза 2 Этап A, Task 4. RecalcAdAudienceJob переехал с булевых ch_yandex/ch_vk/
ch_mts на итерацию строк firm_channels со status='warming' (свой заезд и режим
funnel/flat у каждого канала). Строка, чей срок вышел (decideForChannelRow →
stopped), гаснет warming→loaded (mode/flat_days/warming_started_at обнуляются),
оставаясь загруженной. sms движок не трогает (только CODES yandex/vk/mts).
Сводка (phone.state/stopped_at/ready_at/stop_reason), приоритет reason,
стадийная бухгалтерия и кросс-соединение проспектов (prospectConnection) —
без изменений. Поведение при выкате = как Фаза 1 (бэкфилл warming/funnel).

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

349 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\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));
// Task 4: движок recalc теперь читает строки firm_channels (status='warming'),
// а не булевы ch_*. Зеркалим бэкфилл Task-1: каждый включённый канал ch_* →
// строка warming/funnel с отсчётом от warmup_started_at фирмы. Так старые
// multi-platform тесты (yandex/vk warmup, negotiation, паузы) продолжают
// работать без правки их тел — флаги ch_* превращаются в warming-строки.
foreach (['yandex', 'vk', 'mts'] as $channel) {
if ($firm->{'ch_'.$channel}) {
$firm->firmChannels()->create([
'channel' => $channel,
'status' => 'warming',
'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at,
'warmed_times' => 1,
]);
}
}
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();
});
// ── Task 4: движок бежит по строкам firm_channels (status='warming'), гасит их в loaded ──
it('строка warming/funnel остаётся warming, пока прогрев идёт', function () {
// warmup у площадки 3 дня, warming_started_at строки — 1 день назад ⇒ жива.
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 3]);
$firm = firmWithPhone(['warmup_started_at' => now()->subDay()]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->firmChannels()->where('channel', 'yandex')->first()->status)->toBe('warming')
->and($firm->phones()->first()->state)->toBe('active');
});
it('строка funnel гаснет warming→loaded, когда прогрев кончился', function () {
// warmup 3 дня, warming_started_at — 5 дней назад ⇒ погасла.
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 3]);
$firm = firmWithPhone(['warmup_started_at' => now()->subDays(5)]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
$row = $firm->firmChannels()->where('channel', 'yandex')->first();
expect($row->status)->toBe('loaded')
->and($row->mode)->toBeNull()
->and($row->warming_started_at)->toBeNull()
->and($firm->phones()->first()->state)->toBe('stopped')
->and($firm->stopped_at)->not->toBeNull();
});
it('строка flat гаснет warming→loaded, когда простой срок вышел', function () {
// flat_days=2, warming_started_at 3 дня назад ⇒ простой срок вышел.
// ch_yandex=false, чтобы харнес не создал вторую yandex-строку (UNIQUE firm_id,channel).
$firm = firmWithPhone(['ch_yandex' => false]);
$firm->firmChannels()->create([
'channel' => 'yandex',
'status' => 'warming',
'mode' => 'flat',
'flat_days' => 2,
'warming_started_at' => now()->subDays(3),
]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
$row = $firm->firmChannels()->where('channel', 'yandex')->first();
expect($row->status)->toBe('loaded')
->and($row->mode)->toBeNull()
->and($row->flat_days)->toBeNull()
->and($firm->phones()->first()->state)->toBe('stopped');
});
it('каналы независимы: yandex жив (warming), vk погас (loaded), сводка active', function () {
// yandex warmup 10 (жив на 5-й день), vk warmup 3 (погас на 5-й день).
SalesAdAudiencePlatform::forCode('yandex')->update(['warmup_days' => 10]);
SalesAdAudiencePlatform::forCode('vk')->update(['warmup_days' => 3]);
$firm = firmWithPhone(['warmup_started_at' => now()->subDays(5), 'ch_yandex' => true, 'ch_vk' => true]);
RecalcAdAudienceJob::dispatchSync();
$firm->refresh();
expect($firm->firmChannels()->where('channel', 'yandex')->first()->status)->toBe('warming')
->and($firm->firmChannels()->where('channel', 'vk')->first()->status)->toBe('loaded')
->and($firm->phones()->first()->state)->toBe('active');
});