feat реклама показы: ядро переведено на набор креативов вместо одного адаптивного

Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все
размеры не существует. Медийная кампания состоит из объявления на каждый размер
блока со своим креативом, поэтому строка баннера стала единицей
размер плюс файл плюс креатив плюс объявление.

Что сделано:
- у баннера появились номер креатива, номер объявления и статус модерации
- кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ
- слепок картиночных креативов аккаунта через creatives.get
- опознание своих креативов разницей слепков до и после загрузки по размеру
- запуск заводит объявление на каждый включённый баннер
- модерация считается по каждому объявлению: кампания работает, если принято
  хотя бы одно, а деньги возвращаются только когда отклонены все

Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре.

После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе
джоб модерации молча увидит ноль баннеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-27 14:33:02 +03:00
parent 1a00e215f5
commit c9406533bf
19 changed files with 749 additions and 89 deletions
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncCampaignModerationJob;
use App\Models\AdCampaign;
use App\Models\AdCampaignBanner;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
// Джоб намеренно обходит running/pending-кампании ВСЕХ тенантов — без изоляции
// между кейсами кампания из одного теста осталась бы видна следующему.
uses(RefreshDatabase::class);
// Джоб перечисляет кампании через pgsql_supplier (BYPASSRLS) — без share PDO
// вставленные в тест-транзакции кампании (default pgsql-соединение) не видны
// второму соединению до commit'а.
uses(SharesSupplierPdo::class);
/**
* Рубильник + реквизиты Директа. Своя функция (не общая с CampaignLauncherTest):
* при пофайловом прогоне (paratest) чужой файл в процесс не подгружается.
*/
function configureYandexForModeration(): void
{
config([
'services.yandex_direct.enabled' => true,
'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com',
'services.yandex_direct.token' => 'DIRTOKEN',
]);
}
/**
* Кампания на модерации с набором баннеров, у каждого свой номер объявления.
*
* @param list<int> $adIds
* @return array{0: AdCampaign, 1: Collection<int, AdCampaignBanner>}
*/
function makeModeratedCampaignWithBanners(array $adIds, int $yandexCampaignId = 222): array
{
$tenant = Tenant::factory()->create();
$campaign = AdCampaign::create([
'tenant_id' => $tenant->id,
'name' => 'C',
'mode' => AdCampaign::MODE_MANUAL,
'audience_days' => 10,
'client_cpm_rub' => '120.00',
'status' => AdCampaign::STATUS_PENDING_MODERATION,
'yandex_campaign_id' => $yandexCampaignId,
]);
$w = 300;
$banners = collect($adIds)->map(function (int $adId) use ($campaign, &$w) {
$w += 10;
return AdCampaignBanner::create([
'tenant_id' => $campaign->tenant_id,
'campaign_id' => $campaign->id,
'width' => $w,
'height' => 250,
'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x250.jpg",
'bytes' => 1000,
'included' => true,
'yandex_creative_id' => 4000 + $adId,
'yandex_ad_id' => $adId,
'moderation_status' => AdCampaignBanner::MOD_MODERATION,
]);
});
return [$campaign, $banners];
}
it('keeps the campaign running when at least one banner ad is accepted', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5551, 5552]);
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
$fresh = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('yandex_ad_id')->get();
expect($fresh[0]->moderation_status)->toBe(AdCampaignBanner::MOD_ACCEPTED)
->and($fresh[1]->moderation_status)->toBe(AdCampaignBanner::MOD_REJECTED)
->and($fresh[1]->moderation_reason)->toBe('Текст на баннере');
});
it('rejects the campaign only when every banner ad is rejected', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5551, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'],
['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5551, 5552]);
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED);
});
it('waits while at least one banner ad is still under moderation', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'MODERATION'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5551, 5552]);
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION);
});
// ВЫХОД 2 остаётся ровно один: разморозка зовётся только когда отклонены ВСЕ
// объявления набора. Пока живо хоть одно — деньги остаются замороженными.
it('не снимает заморозку, пока принято хотя бы одно объявление набора', function () {
configureYandexForModeration();
Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [
['Id' => 5561, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'],
['Id' => 5562, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'],
]]])]);
[$campaign] = makeModeratedCampaignWithBanners([5561, 5562], 229);
$svc = app(AdWalletService::class);
$svc->topup((int) $campaign->tenant_id, '3000.00', 'yandex', 'тест');
$svc->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, '1200.00');
(new SyncCampaignModerationJob)->handle();
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING)
->and(AdWallet::where('tenant_id', $campaign->tenant_id)->first()->frozen_rub)->toBe('1200.00');
});