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'); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 31a29c63..5acc2de8 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-31T05:52:15.986Z +Last updated: 2026-07-31T07:17:42.975Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 14.15ч | 14477456.4ч | -| 23936 | Code | 4.26ч | 16668896.4ч | -| 4 | System | 2.32ч | NaNч | +| 3544 | MsMpEng | 16.61ч | NaNч | +| 23936 | Code | 4.76ч | NaNч | +| 4 | System | 2.51ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.