2026-07-19 10:54:21 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\RecalcAdAudienceJob;
|
|
|
|
|
|
use App\Models\SalesAdAudienceFirm;
|
|
|
|
|
|
use App\Models\SalesAdAudiencePhone;
|
2026-07-22 10:46:34 +03:00
|
|
|
|
use App\Models\SalesAdAudiencePlatform;
|
2026-07-19 10:54:21 +03:00
|
|
|
|
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++;
|
|
|
|
|
|
|
2026-07-22 10:46:34 +03:00
|
|
|
|
// Прогреваемая фирма всегда стоит хотя бы на одной площадке. По умолчанию —
|
|
|
|
|
|
// Яндекс (его сроки бэкфилл = дефолты синглтона, поэтому решение по одному
|
|
|
|
|
|
// Яндексу совпадает со старым решением «одной фирмы»). Новые multi-platform
|
|
|
|
|
|
// тесты выставляют ch_vk/ch_mts явно.
|
2026-07-19 10:54:21 +03:00
|
|
|
|
$firm = SalesAdAudienceFirm::create(array_merge([
|
|
|
|
|
|
'firm_name' => 'Тестовая стоматология',
|
|
|
|
|
|
'warmup_started_at' => now(),
|
2026-07-22 10:46:34 +03:00
|
|
|
|
'ch_yandex' => true,
|
2026-07-19 10:54:21 +03:00
|
|
|
|
], $firmAttrs));
|
|
|
|
|
|
|
2026-07-22 15:08:07 +03:00
|
|
|
|
// 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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 10:54:21 +03:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-07-19 17:22:36 +03:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
2026-07-22 10:46:34 +03:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-07-22 15:08:07 +03:00
|
|
|
|
|
|
|
|
|
|
// ── 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');
|
|
|
|
|
|
});
|