'Тестовая стоматология', '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'); });