From e23f5739bf2bb46a9de82dcf7490f3f3fe47fce1 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 14:58:18 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=20=D1=842):=20decider=20=E2=80=94=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B5=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=B0=20(flat-?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20+=20funnel=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 2 Этап A, Task 3. Новый метод PlatformWarmingDecider::decideForChannelRow — решает по строке firm_channels: mode=flat считает простой срок от warming_started_at строки + flat_days (active/stopped(flat_done)); mode=funnel — та же логика decide(), но точка отсчёта warmup берётся из строки канала, а не из firm.warmup_started_at (у каждого канала свой заезд). Существующий decide() не тронут — Фаза 1 цела. AdAudienceScheduler без изменений. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/Sales/PlatformWarmingDecider.php | 29 +++++++ app/tests/Unit/PlatformWarmingDeciderTest.php | 85 ++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/app/app/Services/Sales/PlatformWarmingDecider.php b/app/app/Services/Sales/PlatformWarmingDecider.php index 51d57acf..03ce273d 100644 --- a/app/app/Services/Sales/PlatformWarmingDecider.php +++ b/app/app/Services/Sales/PlatformWarmingDecider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Services\Sales; use App\Models\SalesAdAudienceFirm; +use App\Models\SalesAdAudienceFirmChannel; use App\Models\SalesAdAudiencePlatform; use App\Models\SalesProspect; use Carbon\Carbon; @@ -38,6 +39,34 @@ final class PlatformWarmingDecider ->decide($this->firmArray($firm, $prospect), $now); } + /** + * Решение по СТРОКЕ канала (firm_channels): flat — свой простой срок, + * funnel — та же логика decide(), но точка отсчёта warmup — из строки + * канала, не из firm.warmup_started_at (у каждого канала свой заезд). + * + * @return array{state: string, reason: ?string, resume_at: ?CarbonImmutable} + */ + public function decideForChannelRow( + SalesAdAudienceFirm $firm, + SalesAdAudiencePlatform $platform, + ?SalesProspect $prospect, + SalesAdAudienceFirmChannel $row, + CarbonImmutable $now + ): array { + if ($row->mode === 'flat') { + $started = $row->warming_started_at?->toImmutable() ?? $now; + + return $started->addDays((int) $row->flat_days)->greaterThan($now) + ? ['state' => 'active', 'reason' => null, 'resume_at' => null] + : ['state' => 'stopped', 'reason' => 'flat_done', 'resume_at' => null]; + } + + $arr = $this->firmArray($firm, $prospect); + $arr['warmup_started_at'] = $row->warming_started_at?->toImmutable(); + + return (new AdAudienceScheduler($platform->durations()))->decide($arr, $now); + } + /** * Сборка входа для планировщика — 1:1 как RecalcAdAudienceJob::recalcFirm. * diff --git a/app/tests/Unit/PlatformWarmingDeciderTest.php b/app/tests/Unit/PlatformWarmingDeciderTest.php index ad6d8bb7..ec4b80b0 100644 --- a/app/tests/Unit/PlatformWarmingDeciderTest.php +++ b/app/tests/Unit/PlatformWarmingDeciderTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use App\Models\SalesAdAudienceFirm; +use App\Models\SalesAdAudienceFirmChannel; use App\Models\SalesAdAudiencePlatform; use App\Services\Sales\PlatformWarmingDecider; use Carbon\CarbonImmutable; +use Illuminate\Support\Carbon; use Tests\TestCase; // Модели вне контейнера Laravel не могут выставить datetime-cast атрибут @@ -18,9 +20,9 @@ it('одна фирма: на площадке с большим сроком ac $now = CarbonImmutable::parse('2026-07-22 12:00:00'); $firm = new SalesAdAudienceFirm(['firm_name' => 'X']); - $firm->warmup_started_at = \Illuminate\Support\Carbon::parse('2026-07-18 12:00:00'); // 4 дня назад + $firm->warmup_started_at = Carbon::parse('2026-07-18 12:00:00'); // 4 дня назад - $decider = new PlatformWarmingDecider(); + $decider = new PlatformWarmingDecider; $long = new SalesAdAudiencePlatform(['platform' => 'yandex']); $long->warmup_days = 5; @@ -34,3 +36,82 @@ it('одна фирма: на площадке с большим сроком ac expect($shortDecision['state'])->toBe('stopped'); expect($shortDecision['reason'])->toBe('ready_for_manager'); }); + +it('flat active: строка канала mode=flat, flat_days больше прошедшего срока — active', function () { + $now = CarbonImmutable::parse('2026-07-22 12:00:00'); + + $firm = new SalesAdAudienceFirm(['firm_name' => 'X']); + $platform = new SalesAdAudiencePlatform(['platform' => 'yandex']); + $platform->warmup_days = 3; + + $row = new SalesAdAudienceFirmChannel([ + 'channel' => 'yandex', + 'status' => 'warming', + 'mode' => 'flat', + 'flat_days' => 10, + ]); + $row->warming_started_at = Carbon::parse('2026-07-18 12:00:00'); // 4 дня назад + + $decider = new PlatformWarmingDecider; + + $decision = $decider->decideForChannelRow($firm, $platform, null, $row, $now); + + expect($decision['state'])->toBe('active'); +}); + +it('flat done: строка канала mode=flat, flat_days меньше прошедшего срока — stopped(flat_done)', function () { + $now = CarbonImmutable::parse('2026-07-22 12:00:00'); + + $firm = new SalesAdAudienceFirm(['firm_name' => 'X']); + $platform = new SalesAdAudiencePlatform(['platform' => 'yandex']); + $platform->warmup_days = 3; + + $row = new SalesAdAudienceFirmChannel([ + 'channel' => 'yandex', + 'status' => 'warming', + 'mode' => 'flat', + 'flat_days' => 3, + ]); + $row->warming_started_at = Carbon::parse('2026-07-17 12:00:00'); // 5 дней назад + + $decider = new PlatformWarmingDecider; + + $decision = $decider->decideForChannelRow($firm, $platform, null, $row, $now); + + expect($decision['state'])->toBe('stopped'); + expect($decision['reason'])->toBe('flat_done'); +}); + +it('funnel из строки: срок берётся из warming_started_at строки канала, не из firm.warmup_started_at', function () { + $now = CarbonImmutable::parse('2026-07-22 12:00:00'); + + $firm = new SalesAdAudienceFirm(['firm_name' => 'X']); + // Умышленно НЕ ставим firm->warmup_started_at — decideForChannelRow должен + // взять срок из строки канала, а не из фирмы. + + $platform = new SalesAdAudiencePlatform(['platform' => 'yandex']); + $platform->warmup_days = 3; + + $decider = new PlatformWarmingDecider; + + $rowActive = new SalesAdAudienceFirmChannel([ + 'channel' => 'yandex', + 'status' => 'warming', + 'mode' => 'funnel', + ]); + $rowActive->warming_started_at = Carbon::parse('2026-07-20 12:00:00'); // 2 дня назад + + $activeDecision = $decider->decideForChannelRow($firm, $platform, null, $rowActive, $now); + expect($activeDecision['state'])->toBe('active'); + + $rowStopped = new SalesAdAudienceFirmChannel([ + 'channel' => 'yandex', + 'status' => 'warming', + 'mode' => 'funnel', + ]); + $rowStopped->warming_started_at = Carbon::parse('2026-07-17 12:00:00'); // 5 дней назад + + $stoppedDecision = $decider->decideForChannelRow($firm, $platform, null, $rowStopped, $now); + expect($stoppedDecision['state'])->toBe('stopped'); + expect($stoppedDecision['reason'])->toBe('ready_for_manager'); +});