feat(прогрев ф2): Яндекс-заливка берёт только строки firm_channels warming+active

Фаза 2 Этап A, Task 5. SyncAdAudienceJob выбирает фирмы не по ch_yandex, а по
наличию строки firm_channels(channel=yandex, status=warming), и решает по этой
строке через decideForChannelRow (funnel/flat). Фирма с yandex-строкой loaded
(не греется) в сегмент не попадает, даже если ch_yandex=true. Kill-switch
enabled и token-guard — не тронуты (защита денег владельца). prospectConnection,
client/replace/last_error — без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-22 15:19:13 +03:00
parent 475d814100
commit c7831e3f14
2 changed files with 93 additions and 7 deletions
+17 -7
View File
@@ -63,15 +63,21 @@ class SyncAdAudienceJob implements ShouldQueue
return;
}
// Состав считаем пофирменно по срокам Яндекса. Берём только фирмы, кого
// начальник пометил Яндексом (v8.79); номер без хозяина (сирота до v8.77)
// в фирмы не входит и в сегмент не попадёт. Решение по срокам ЯНДЕКСА, а не
// по сводному phone.state: погасшая по Яндексу фирма уходит из сегмента,
// даже если сводка active из-за другой площадки.
// Состав считаем пофирменно по срокам Яндекса. Берём только фирмы, у кого
// ЕСТЬ строка firm_channels(channel=yandex, status=warming) — Фаза 2:
// раздельные каналы, ch_yandex больше не источник (та фирма могла уже
// выгореть на Яндексе и стоять только на другой площадке). Номер без
// хозяина (сирота до v8.77) в фирмы не входит и в сегмент не попадёт.
// Решение по срокам ЯНДЕКСА конкретной СТРОКИ канала, а не по сводному
// phone.state: погасшая по Яндексу фирма уходит из сегмента, даже если
// сводка active из-за другой площадки (funnel/flat решает decideForChannelRow).
$now = CarbonImmutable::now();
$decider = new PlatformWarmingDecider;
$firms = SalesAdAudienceFirm::forYandex()->with('phones')->get();
$firms = SalesAdAudienceFirm::whereHas(
'firmChannels',
fn ($q) => $q->where('channel', 'yandex')->where('status', 'warming')
)->with(['phones', 'firmChannels'])->get();
/** @var Collection<int, SalesProspect> $prospects */
$prospects = SalesProspect::on($this->prospectConnection)
@@ -82,8 +88,12 @@ class SyncAdAudienceJob implements ShouldQueue
/** @var array<int, string> $phones */
$phones = [];
foreach ($firms as $firm) {
$row = $firm->channel('yandex');
if ($row === null) {
continue;
}
$prospect = $firm->prospect_id ? $prospects->get($firm->prospect_id) : null;
if ($decider->decide($firm, $platform, $prospect, $now)['state'] !== 'active') {
if ($decider->decideForChannelRow($firm, $platform, $prospect, $row, $now)['state'] !== 'active') {
continue;
}
foreach ($firm->phones as $phone) {
@@ -39,6 +39,10 @@ it('номер без хозяина в рекламу не попадает', f
]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тестовая стоматология', 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => $firm->warmup_started_at ?? now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create(['phone' => '79990000001', 'firm_id' => $firm->id, 'expires_at' => now()->addDays(3), 'state' => 'active']);
SalesAdAudiencePhone::create(['phone' => '79990000009', 'firm_id' => null, 'expires_at' => now()->addDays(3), 'state' => 'active']);
@@ -74,6 +78,10 @@ it('заливает номера фирмы, активной по срокам
$fresh = SalesAdAudienceFirm::create([
'firm_name' => 'Свежая', 'warmup_started_at' => now(), 'ch_yandex' => true,
]);
$fresh->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000001', 'firm_id' => $fresh->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
@@ -83,6 +91,10 @@ it('заливает номера фирмы, активной по срокам
$gone = SalesAdAudienceFirm::create([
'firm_name' => 'Погасшая', 'warmup_started_at' => now()->subDays(10), 'ch_yandex' => true,
]);
$gone->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(10), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000002', 'firm_id' => $gone->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
@@ -127,6 +139,10 @@ it('фирму, помеченную только для ВК, в Яндекс
$yandexFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Греем в Яндексе', 'warmup_started_at' => now(), 'ch_yandex' => true,
]);
$yandexFirm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000001', 'firm_id' => $yandexFirm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
@@ -135,6 +151,10 @@ it('фирму, помеченную только для ВК, в Яндекс
$vkFirm = SalesAdAudienceFirm::create([
'firm_name' => 'Греем в ВК', 'warmup_started_at' => now(), 'ch_vk' => true,
]);
$vkFirm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000002', 'firm_id' => $vkFirm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
@@ -168,6 +188,14 @@ it('фирма, погасшая по срокам Яндекса, в сегме
'firm_name' => 'На обеих', 'warmup_started_at' => now()->subDays(5),
'ch_yandex' => true, 'ch_vk' => true,
]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(5), 'warmed_times' => 1,
]);
$firm->firmChannels()->create([
'channel' => 'vk', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(5), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000077', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active', // сводка active
@@ -183,3 +211,51 @@ it('фирма, погасшая по срокам Яндекса, в сегме
return true;
});
});
it('фирма со строкой yandex warming+active — номер попадает в сегмент (Task 5)', function () {
// Источник Task 5: не forYandex()/ch_yandex, а строка firm_channels
// (channel=yandex, status=warming). Решение по ней же — decideForChannelRow.
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Тёплая по Яндексу', 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'warming', 'mode' => 'funnel',
'warming_started_at' => now(), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000031', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $r) {
$file = collect($r->data())->firstWhere('name', 'file');
$csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body();
return str_contains($r->url(), 'modify_data') && str_contains($csv, '79990000031');
});
});
it('фирма со строкой yandex loaded (не греется) — номер НЕ в сегменте, даже с ch_yandex=true (Task 5)', function () {
Http::fake(['api-audience.yandex.ru/*' => Http::response(['result' => true], 200)]);
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Уже загруженная', 'ch_yandex' => true]);
$firm->firmChannels()->create([
'channel' => 'yandex', 'status' => 'loaded', 'mode' => 'funnel',
'warming_started_at' => now()->subDays(20), 'warmed_times' => 1,
]);
SalesAdAudiencePhone::create([
'phone' => '79990000032', 'firm_id' => $firm->id,
'expires_at' => now()->addDays(30), 'state' => 'active',
]);
SyncAdAudienceJob::dispatchSync();
Http::assertSent(function (Request $r) {
$file = collect($r->data())->firstWhere('name', 'file');
$csv = is_array($file) && is_string($file['contents'] ?? null) ? $file['contents'] : (string) $r->body();
return str_contains($r->url(), 'modify_data') && ! str_contains($csv, '79990000032');
});
});