Files
portal/app/tests/Feature/Sales/AdAudienceRecalcTest.php
T
Дмитрий 2228bae51d fix(sales): ночной пересчёт рекламы читает карточки ролью с правами
На бою джоб бежит вне веб-запроса, то есть на дефолтной роли crm_app_user,
у которой нет прав на sales_prospects — пересчёт падал с permission denied
и не работал вообще. Планировщик теперь передаёт pgsql_supplier, как это
делает SalesProspectsAdvanceJob. Регресс-тест закрывает.

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

181 lines
7.2 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');
});
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');
});