From 16091204e0abd766cdc68a8bf0edaa1dbf4de734 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: Fri, 31 Jul 2026 09:13:27 +0300 Subject: [PATCH] =?UTF-8?q?fix=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=8F=D1=82=D1=8B=D0=B5=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D1=83=D1=85=D0=BE=D0=B4=D1=8F=D1=82=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=20=E2=80=94=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=D0=BD=D1=8F?= =?UTF-8?q?=D0=BB=D0=B0,=20=D1=8D=D1=82=D0=BE=20=D0=B5=D1=89=D1=91=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У объявления два независимых признака: вердикт модерации (Status) и идёт ли показ (State). Созданное программой объявление рождается выключенным, и включение кампании его не поднимает — команды поднять сами объявления в коде не было вообще. Живая приёмка 31.07.2026, кампания 713175197: принята и включена, Яндекс на уровне кампании пишет «Идут показы», а внутри все 15 объявлений выключены. Показов ноль при замороженных у клиента 3333.36 руб. Опросчик теперь чинит по СОСТОЯНИЮ из ответа Яндекса, а не по переходу статуса: собирает объявления со статусом «принято» и выключенным показом и зовёт ads.resume. Так самовосстанавливается и уже запущенная кампания — по переходу она осталась бы выключенной навсегда, потому что «принято» у нас записано давно. Состояние Яндекс присылал в каждом ответе и раньше, опросчик его просто выбрасывал. Проверено вырезанием: без починки новый тест краснеет. Реклама 349 из 349. --- app/app/Jobs/SyncCampaignModerationJob.php | 22 +++++++ .../Advertising/YandexDirectClient.php | 23 ++++++++ .../SyncCampaignModerationJobTest.php | 58 +++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php index fe71cd99..3705d351 100644 --- a/app/app/Jobs/SyncCampaignModerationJob.php +++ b/app/app/Jobs/SyncCampaignModerationJob.php @@ -77,6 +77,12 @@ class SyncCampaignModerationJob implements ShouldQueue $banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all() ); + // Принятые модерацией, но с выключенным показом — их надо поднять. + // Собираем по СОСТОЯНИЮ из ответа Яндекса, а не по переходу статуса: + // у давно запущенной кампании «принято» записано у нас неделю назад, + // перехода уже не будет, и по переходу она осталась бы выключенной навсегда. + $vyklyuchennye = []; + foreach ($banners as $banner) { $info = $moderation[(int) $banner->yandex_ad_id] ?? null; if ($info === null) { @@ -110,6 +116,10 @@ class SyncCampaignModerationJob implements ShouldQueue 'moderation_reason' => $reason === null ? null : mb_substr($reason, 0, 255), ]); + if ($status === AdCampaignBanner::MOD_ACCEPTED && ($info['state'] ?? null) === 'OFF') { + $vyklyuchennye[] = (int) $banner->yandex_ad_id; + } + // Пояснение модератора кладём в ленту кампании ЦЕЛИКОМ: в колонке // баннера оно обрезано до 255 знаков ради ярлыка, а клиенту нужен // весь текст — именно по нему он поймёт, что переделывать. @@ -157,6 +167,18 @@ class SyncCampaignModerationJob implements ShouldQueue } } } + + // Отдельный try: показ — вещь важная, но неудача включения не должна + // стоить кампании записанного вердикта модерации и сорвать обход остальных. + if ($vyklyuchennye !== []) { + try { + $direct->resumeAds($vyklyuchennye); + } catch (Throwable $e) { + Log::warning('Не смогли включить показ принятых объявлений: '.$e->getMessage(), [ + 'campaign' => $campaign->id, 'ads' => count($vyklyuchennye), + ]); + } + } } catch (Throwable $e) { // ПДн в лог не попадают — только id кампании. Log::warning('SyncCampaignModerationJob: '.$e->getMessage(), ['campaign' => $campaign->id]); diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php index ff6a3117..fa3aa9e5 100644 --- a/app/app/Services/Advertising/YandexDirectClient.php +++ b/app/app/Services/Advertising/YandexDirectClient.php @@ -329,6 +329,29 @@ final class YandexDirectClient $this->call('v5/ads', 'moderate', ['SelectionCriteria' => ['Ids' => $adIds]]); } + /** + * Ads.resume — включить показ объявлений. + * + * 🔴 У объявления ДВА независимых признака: `Status` — вердикт модерации, `State` — + * идёт ли показ. Модерация приняла — это ещё не показ. Созданное программой + * объявление рождается со `State: OFF` и таким остаётся, пока его не включить. + * Включение кампании (`campaigns.resume`) объявления НЕ поднимает. + * + * Живая приёмка 31.07.2026: кампания 713175197 включена, Яндекс на уровне кампании + * пишет «Идут показы», а внутри все 15 объявлений выключены — показов ноль при + * замороженных у клиента деньгах. Команды включить объявления не было в коде вообще. + * + * @param list $adIds + */ + public function resumeAds(array $adIds): void + { + if ($adIds === []) { + return; + } + + $this->call('v5/ads', 'resume', ['SelectionCriteria' => ['Ids' => $adIds]]); + } + /** Campaigns.suspend — пауза показа. */ public function suspendCampaign(int $campaignId): void { diff --git a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php index 22407699..d65e86cd 100644 --- a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php +++ b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php @@ -522,3 +522,61 @@ it('пока разведка не сходила, заглушка клиент expect($first)->not->toBeNull() ->and($first->body)->toContain('причину не назвал'); }); + +/** + * 🔴 Модерация приняла — это ещё НЕ показ. У объявления два независимых признака: + * вердикт (Status) и включён ли показ (State). Созданное программой объявление + * рождается выключенным, и включение кампании его не поднимает. + * + * Живая приёмка 31.07.2026: кампания принята и включена, Яндекс пишет «Идут показы», + * а внутри все 15 объявлений выключены — показов ноль при замороженных деньгах клиента. + */ +it('включает принятые объявления, которые Яндекс держит выключенными', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7701, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ['Id' => 7702, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ]]])]); + + makeModeratedCampaignWithBanners([7701, 7702]); + + (new SyncCampaignModerationJob)->handle(); + + Http::assertSent(function ($request) { + $data = $request->data(); + + return ($data['method'] ?? null) === 'resume' + && ($data['params']['SelectionCriteria']['Ids'] ?? []) === [7701, 7702]; + }); +}); + +/** + * Обратная сторона: лишний раз дёргать Яндекс нельзя. Объявление, которое уже + * показывается, включать не за чем — иначе на каждом обходе улетал бы холостой запрос. + */ +it('не дёргает включение у объявления, которое и так показывается', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7703, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ]]])]); + + makeModeratedCampaignWithBanners([7703]); + + (new SyncCampaignModerationJob)->handle(); + + Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'resume'); +}); + +/** Отклонённое объявление включать нельзя: показывать его Яндекс всё равно не будет. */ +it('не включает отклонённое объявление', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7704, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'], + ]]])]); + + makeModeratedCampaignWithBanners([7704]); + + (new SyncCampaignModerationJob)->handle(); + + Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'resume'); +});