feat(прогрев): recalc сводит per-platform решения в firm-level сводку

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-22 10:46:34 +03:00
parent 2c3698c615
commit bd2def5fc8
2 changed files with 210 additions and 26 deletions
+130 -26
View File
@@ -6,10 +6,9 @@ namespace App\Jobs;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudienceState;
use App\Models\SalesAdAudiencePlatform;
use App\Models\SalesProspect;
use App\Services\Sales\AdAudienceScheduler;
use Carbon\Carbon;
use App\Services\Sales\PlatformWarmingDecider;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -49,15 +48,36 @@ class RecalcAdAudienceJob implements ShouldQueue
*/
public function __construct(private readonly ?string $prospectConnection = null) {}
/**
* Приоритет reason при сводке нескольких площадок (spec §3.4):
* ready_for_manager > client_won > {stage_limit/call_overdue/call_far} > прочее.
*
* @var list<string>
*/
private const REASON_PRIORITY = [
'ready_for_manager',
'client_won',
'stage_limit',
'call_overdue',
'call_far',
'unknown_stage',
];
public function handle(): void
{
$state = SalesAdAudienceState::current();
$scheduler = new AdAudienceScheduler($state->durations());
// Строки площадок (yandex/vk/mts) со своими сроками — читаем один раз.
/** @var Collection<string, SalesAdAudiencePlatform> $platforms */
$platforms = SalesAdAudiencePlatform::query()
->whereIn('platform', SalesAdAudiencePlatform::CODES)
->get()
->keyBy('platform');
$decider = new PlatformWarmingDecider;
$now = CarbonImmutable::now();
SalesAdAudienceFirm::query()
->with('phones')
->chunkById(200, function (Collection $firms) use ($scheduler, $now): void {
->chunkById(200, function (Collection $firms) use ($platforms, $decider, $now): void {
/** @var Collection<int, SalesAdAudienceFirm> $firms */
$prospectIds = $firms->pluck('prospect_id')->filter()->all();
@@ -68,16 +88,20 @@ class RecalcAdAudienceJob implements ShouldQueue
->keyBy('id');
foreach ($firms as $firm) {
$this->recalcFirm($firm, $prospects, $scheduler, $now);
$this->recalcFirm($firm, $prospects, $platforms, $decider, $now);
}
});
}
/** @param Collection<int, SalesProspect> $prospects */
/**
* @param Collection<int, SalesProspect> $prospects
* @param Collection<string, SalesAdAudiencePlatform> $platforms
*/
private function recalcFirm(
SalesAdAudienceFirm $firm,
Collection $prospects,
AdAudienceScheduler $scheduler,
Collection $platforms,
PlatformWarmingDecider $decider,
CarbonImmutable $now
): void {
$prospect = $firm->prospect_id ? $prospects->get($firm->prospect_id) : null;
@@ -90,40 +114,120 @@ class RecalcAdAudienceJob implements ShouldQueue
$firm->stage_changed_at = now();
}
$decision = $scheduler->decide([
'stage' => $prospect?->stage,
'warmup_started_at' => $this->immutable($firm->warmup_started_at),
'assigned_at' => $this->immutable($firm->assigned_at),
// Запасной вариант ($firm->assigned_at) — на случай, если стадия ни разу
// не менялась под новым кодом, а stage_changed_at всё ещё пуст.
'stage_changed_at' => $this->immutable($firm->stage_changed_at ?? $firm->assigned_at),
'next_call_at' => $this->immutable($prospect?->next_call_at),
], $now);
// Решение по КАЖДОЙ подписанной площадке отдельно (её сроки).
$subscribed = array_keys(array_filter([
'yandex' => $firm->ch_yandex,
'vk' => $firm->ch_vk,
'mts' => $firm->ch_mts,
]));
$decisions = [];
foreach ($subscribed as $code) {
if (! $platforms->has($code)) {
continue;
}
$decisions[] = $decider->decide($firm, $platforms->get($code), $prospect, $now);
}
$summaryState = $this->summaryState($decisions);
$summaryResumeAt = $this->summaryResumeAt($decisions, $summaryState);
$summaryReason = $this->summaryReason($decisions, $summaryState);
/** @var Collection<int, SalesAdAudiencePhone> $phones */
$phones = $firm->phones;
$phones->each(function (SalesAdAudiencePhone $phone) use ($decision): void {
$phone->state = $decision['state'];
$phone->resume_at = $decision['resume_at']?->toMutable();
$phones->each(function (SalesAdAudiencePhone $phone) use ($summaryState, $summaryResumeAt): void {
$phone->state = $summaryState;
$phone->resume_at = $summaryResumeAt?->toMutable();
if ($phone->isDirty(['state', 'resume_at'])) {
$phone->synced_at = null; // состав аудитории изменился — надо залить заново
$phone->save();
}
});
$firm->stop_reason = $decision['reason'];
$firm->stopped_at = $decision['state'] === 'stopped' ? ($firm->stopped_at ?? now()) : null;
$firm->stop_reason = $summaryReason;
// spec §3.4: stopped_at выставляется, когда фирма НЕ active ни на одной
// подписанной площадке (paused ИЛИ stopped).
$firm->stopped_at = $summaryState === 'active' ? null : ($firm->stopped_at ?? now());
if ($decision['reason'] === 'ready_for_manager') {
if ($summaryReason === 'ready_for_manager') {
$firm->ready_at ??= now();
}
$firm->save();
}
private function immutable(?Carbon $value): ?CarbonImmutable
/**
* active, если хоть одна площадка active; иначе paused, если хоть одна paused;
* иначе stopped (в т.ч. когда фирма не подписана ни на одну площадку).
*
* @param list<array{state: string, reason: ?string, resume_at: ?CarbonImmutable}> $decisions
*/
private function summaryState(array $decisions): string
{
return $value?->toImmutable();
foreach ($decisions as $d) {
if ($d['state'] === 'active') {
return 'active';
}
}
foreach ($decisions as $d) {
if ($d['state'] === 'paused') {
return 'paused';
}
}
return 'stopped';
}
/**
* Для paused самый ранний resume_at среди площадок на паузе; иначе null.
*
* @param list<array{state: string, reason: ?string, resume_at: ?CarbonImmutable}> $decisions
*/
private function summaryResumeAt(array $decisions, string $summaryState): ?CarbonImmutable
{
if ($summaryState !== 'paused') {
return null;
}
$earliest = null;
foreach ($decisions as $d) {
if ($d['state'] !== 'paused' || ! $d['resume_at'] instanceof CarbonImmutable) {
continue;
}
if ($earliest === null || $d['resume_at']->lessThan($earliest)) {
$earliest = $d['resume_at'];
}
}
return $earliest;
}
/**
* Для active null. Иначе reason из непустых (state !== active) решений,
* стоящий раньше всех в REASON_PRIORITY.
*
* @param list<array{state: string, reason: ?string, resume_at: ?CarbonImmutable}> $decisions
*/
private function summaryReason(array $decisions, string $summaryState): ?string
{
if ($summaryState === 'active') {
return null;
}
$reasons = [];
foreach ($decisions as $d) {
if ($d['state'] !== 'active' && $d['reason'] !== null) {
$reasons[$d['reason']] = true;
}
}
foreach (self::REASON_PRIORITY as $reason) {
if (isset($reasons[$reason])) {
return $reason;
}
}
return null;
}
}
@@ -5,6 +5,7 @@ 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;
@@ -30,9 +31,14 @@ 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));
SalesAdAudiencePhone::create([
@@ -178,3 +184,77 @@ it('читает карточки тем соединением, которое
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();
});