feat(прогрев ф2): decider — решение по строке канала (flat-режим + funnel из строки)

Фаза 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-22 14:58:18 +03:00
parent 80b85a7a02
commit e23f5739bf
2 changed files with 112 additions and 2 deletions
@@ -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.
*
+83 -2
View File
@@ -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');
});