From bd2def5fc81ce0bae40e1b7cd140497fad84a7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 22 Jul 2026 10:46:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D0=B2?= =?UTF-8?q?):=20recalc=20=D1=81=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=20per-platfo?= =?UTF-8?q?rm=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20firm?= =?UTF-8?q?-level=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/Jobs/RecalcAdAudienceJob.php | 156 +++++++++++++++--- .../Feature/Sales/AdAudienceRecalcTest.php | 80 +++++++++ 2 files changed, 210 insertions(+), 26 deletions(-) diff --git a/app/app/Jobs/RecalcAdAudienceJob.php b/app/app/Jobs/RecalcAdAudienceJob.php index 502f432c..8e2f962c 100644 --- a/app/app/Jobs/RecalcAdAudienceJob.php +++ b/app/app/Jobs/RecalcAdAudienceJob.php @@ -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 + */ + 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 $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 $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 $prospects */ + /** + * @param Collection $prospects + * @param Collection $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 $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 $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 $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 $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; } } diff --git a/app/tests/Feature/Sales/AdAudienceRecalcTest.php b/app/tests/Feature/Sales/AdAudienceRecalcTest.php index 18dfc3c7..c7c0a9d8 100644 --- a/app/tests/Feature/Sales/AdAudienceRecalcTest.php +++ b/app/tests/Feature/Sales/AdAudienceRecalcTest.php @@ -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(); +});