Files
portal/app/tests/Feature/Sales/AdAudienceRecalcTest.php
T
Дмитрий f6abd1764d feat(sales): фирма на прогреве помнит увиденную стадию — честный ночной пересчёт рекламы
Task 5 плана v2: RecalcAdAudienceJob (routes/console.php, дневное 03:10 МСК) ночью
сверяет стадию связанной карточки sales_prospects с последней запомненной у фирмы
(sales_ad_audience_firms.stage_seen/stage_changed_at) и зовёт AdAudienceScheduler,
чтобы проставить каждому номеру состояние active/paused/stopped.

Найдена и закрыта дыра в исходном плане: ни prospect->updated_at (двигается ЛЮБОЙ
правкой карточки — заметка менеджера ложно продлевала бы рекламу), ни firm->assigned_at
(момент назначения менеджера, не смены стадии) не годились источником даты «сколько
фирма сидит в текущей стадии». Решение: фирма сама хранит эту дату — миграция
2026_07_20_110000 (+2 nullable-колонки, db/CHANGELOG_schema.md v8.78). Новый тест
«правка карточки не перезапускает отсчёт рекламы» закрывает ровно эту дыру.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:54:21 +03:00

155 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\RecalcAdAudienceJob;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
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++;
$firm = SalesAdAudienceFirm::create(array_merge([
'firm_name' => 'Тестовая стоматология',
'warmup_started_at' => now(),
], $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');
});