From c7831e3f14b22c366e8d4dfddf81bdf4abcbc54a 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 15:19:13 +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):=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81-=D0=B7?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B2=D0=BA=D0=B0=20=D0=B1=D0=B5=D1=80=D1=91?= =?UTF-8?q?=D1=82=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8=20firm=5Fchannels=20warming+active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 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) --- app/app/Jobs/SyncAdAudienceJob.php | 24 ++++-- .../Feature/Sales/AdAudienceSyncTest.php | 76 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/app/app/Jobs/SyncAdAudienceJob.php b/app/app/Jobs/SyncAdAudienceJob.php index 3571c801..7b46e04f 100644 --- a/app/app/Jobs/SyncAdAudienceJob.php +++ b/app/app/Jobs/SyncAdAudienceJob.php @@ -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 $prospects */ $prospects = SalesProspect::on($this->prospectConnection) @@ -82,8 +88,12 @@ class SyncAdAudienceJob implements ShouldQueue /** @var array $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) { diff --git a/app/tests/Feature/Sales/AdAudienceSyncTest.php b/app/tests/Feature/Sales/AdAudienceSyncTest.php index 2c2d6a06..f4faa77b 100644 --- a/app/tests/Feature/Sales/AdAudienceSyncTest.php +++ b/app/tests/Feature/Sales/AdAudienceSyncTest.php @@ -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'); + }); +});