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'); +});