feat(прогрев): recalc сводит per-platform решения в firm-level сводку
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user