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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user