diff --git a/app/app/Exceptions/Advertising/CreativeMatchFailedException.php b/app/app/Exceptions/Advertising/CreativeMatchFailedException.php new file mode 100644 index 00000000..9837f0eb --- /dev/null +++ b/app/app/Exceptions/Advertising/CreativeMatchFailedException.php @@ -0,0 +1,14 @@ +update() (ad и campaign) идут по BYPASSRLS, пишем строго + * соединение — их ->update() (баннер и кампания) идут по BYPASSRLS, пишем строго * по загруженным строкам (без доп. tenant-фильтра). * * Рубильник: services.yandex_direct.enabled — пока выключен, джоб не делает @@ -52,17 +57,19 @@ class SyncCampaignModerationJob implements ShouldQueue $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [ AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION, - ])->whereNotNull('yandex_campaign_id')->with('ads')->get(); + ])->whereNotNull('yandex_campaign_id')->with('banners')->get(); foreach ($campaigns as $campaign) { - $ads = $campaign->ads->filter(fn ($ad) => $ad->yandex_ad_id !== null); + $banners = $campaign->banners->filter(fn ($b) => $b->yandex_ad_id !== null); - if ($ads->isEmpty()) { + if ($banners->isEmpty()) { continue; } try { - $moderation = $direct->getAdsModeration($ads->pluck('yandex_ad_id')->all()); + $moderation = $direct->getAdsModeration( + $banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all() + ); } catch (Throwable $e) { // Сбой одной кампании не должен валить весь опрос остальных. // ПДн в лог не попадают — только id кампании. @@ -71,53 +78,58 @@ class SyncCampaignModerationJob implements ShouldQueue continue; } - $hasRejected = false; - $rejectedReason = null; - $allAccepted = true; - - foreach ($ads as $ad) { - $info = $moderation[(int) $ad->yandex_ad_id] ?? null; + foreach ($banners as $banner) { + $info = $moderation[(int) $banner->yandex_ad_id] ?? null; if ($info === null) { - $allAccepted = false; - continue; } - $status = $info['status'] ?? null; - $reason = $info['reason'] ?? null; - - $ad->update([ - 'moderation_status' => $status, - 'moderation_reason' => $reason, + $banner->update([ + 'moderation_status' => $info['status'] ?? null, + 'moderation_reason' => $info['reason'] ?? null, ]); - - if ($status === 'REJECTED') { - if (! $hasRejected) { - $hasRejected = true; - $rejectedReason = $reason; - } - } elseif ($status !== 'ACCEPTED') { - $allAccepted = false; - } } - $newStatus = $hasRejected - ? AdCampaign::STATUS_REJECTED - : ($allAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_PENDING_MODERATION); + // Перечитывать баннеры не нужно: ->update() уже положил новые значения + // в ту же модель в памяти. Баннер, про который Яндекс промолчал, остаётся + // со своим прежним статусом — то есть считается ещё не решённым и держит + // кампанию в ожидании (как и раньше при пропаже объявления в ответе). + $anyAccepted = $banners->contains( + fn ($b) => $b->moderation_status === AdCampaignBanner::MOD_ACCEPTED + ); + $anyPending = $banners->contains(fn ($b) => ! in_array( + $b->moderation_status, [AdCampaignBanner::MOD_ACCEPTED, AdCampaignBanner::MOD_REJECTED], true + )); - if ($newStatus !== $campaign->status) { - $campaign->update([ - 'status' => $newStatus, - 'moderation_reason' => $hasRejected ? $rejectedReason : $campaign->moderation_reason, - ]); + // Правило спеки §6: кампания работает, если принято ХОТЯ БЫ ОДНО объявление — + // отклонённые просто не показываются. В «отклонено» с разморозкой денег уходим, + // только когда отклонены ВСЕ. Так не появляется пятый выход кампании: существующие + // четыре выхода снятия заморозки остаются как есть. + if ($anyPending) { + continue; // ждём вердикта по остальным, статус кампании не трогаем + } - // ВЫХОД 2 — Яндекс отклонил объявление. Показов не было, деньги не - // тратились: заморозку возвращаем клиенту целиком, кампания мертва. - if ($newStatus === AdCampaign::STATUS_REJECTED) { - app(AdWalletService::class)->release( - (int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, - ); - } + $newStatus = $anyAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_REJECTED; + + if ($newStatus === $campaign->status) { + continue; + } + + $rejectedReason = $anyAccepted + ? $campaign->moderation_reason + : $banners->pluck('moderation_reason')->first(fn ($r) => $r !== null && $r !== ''); + + $campaign->update([ + 'status' => $newStatus, + 'moderation_reason' => $rejectedReason, + ]); + + // ВЫХОД 2 — Яндекс отклонил ВЕСЬ набор объявлений. Показов не было, деньги + // не тратились: заморозку возвращаем клиенту целиком, кампания мертва. + if ($newStatus === AdCampaign::STATUS_REJECTED) { + app(AdWalletService::class)->release( + (int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, + ); } } } diff --git a/app/app/Models/AdCampaign.php b/app/app/Models/AdCampaign.php index b6d6cc96..bb415ca1 100644 --- a/app/app/Models/AdCampaign.php +++ b/app/app/Models/AdCampaign.php @@ -138,6 +138,15 @@ class AdCampaign extends Model return $this->hasMany(AdCampaignAd::class, 'campaign_id'); } + /** Набор баннеров кампании: строка = «размер + файл + креатив + объявление». + * + * @return HasMany + */ + public function banners(): HasMany + { + return $this->hasMany(AdCampaignBanner::class, 'campaign_id'); + } + /** @return HasMany */ public function phones(): HasMany { diff --git a/app/app/Models/AdCampaignBanner.php b/app/app/Models/AdCampaignBanner.php index a4f4c920..e44f3ed5 100644 --- a/app/app/Models/AdCampaignBanner.php +++ b/app/app/Models/AdCampaignBanner.php @@ -13,8 +13,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; */ class AdCampaignBanner extends Model { + public const MOD_DRAFT = 'draft'; + + public const MOD_MODERATION = 'MODERATION'; + + public const MOD_ACCEPTED = 'ACCEPTED'; + + public const MOD_REJECTED = 'REJECTED'; + protected $fillable = [ 'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'included', + 'yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason', ]; protected function casts(): array @@ -26,6 +35,8 @@ class AdCampaignBanner extends Model 'height' => 'integer', 'bytes' => 'integer', 'included' => 'boolean', + 'yandex_creative_id' => 'integer', + 'yandex_ad_id' => 'integer', ]; } diff --git a/app/app/Services/Advertising/BannerUploadPolicy.php b/app/app/Services/Advertising/BannerUploadPolicy.php index eac708d1..6576bbd8 100644 --- a/app/app/Services/Advertising/BannerUploadPolicy.php +++ b/app/app/Services/Advertising/BannerUploadPolicy.php @@ -6,7 +6,10 @@ namespace App\Services\Advertising; final class BannerUploadPolicy { - public const MAX_BYTES = 153600; // 150 КБ — наш текущий кап (сверка с Яндексом — Часть 4) + // 512 000 байт — предел Яндекса для графического креатива медийной кампании. Сверено + // с живой кампанией прогрева: 15 баннеров ≤ 512 КБ приняты кабинетом (HANDOFF 25.07 §5.1). + // Прежнее значение 150 КБ было нашим временным капом до сверки. + public const MAX_BYTES = 512000; /** @var list */ public const FORMATS = ['jpg', 'jpeg', 'png', 'gif']; diff --git a/app/app/Services/Advertising/CampaignLauncher.php b/app/app/Services/Advertising/CampaignLauncher.php index 94591b58..fb29bee2 100644 --- a/app/app/Services/Advertising/CampaignLauncher.php +++ b/app/app/Services/Advertising/CampaignLauncher.php @@ -6,6 +6,7 @@ namespace App\Services\Advertising; use App\Exceptions\Advertising\AudienceTooSmallException; use App\Models\AdCampaign; +use App\Models\AdCampaignBanner; use App\Services\Sales\YandexAudienceClient; use Illuminate\Support\Facades\DB; use RuntimeException; @@ -34,8 +35,24 @@ final class CampaignLauncher throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).'); } - if ($campaign->yandex_creative_id === null) { - throw new RuntimeException('У кампании не указан номер креатива Яндекса — оператору оформить креатив в конструкторе и вписать номер.'); + // Конструктор креативов Яндекса закрыт — креатив у каждого размера свой, номера + // проставляет робот-грузчик либо оператор руками. Запускаться без номеров нельзя: + // объявление уедет пустым. Основание — findings 2026-07-27. + $banners = AdCampaignBanner::where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->where('included', true) + ->orderBy('width')->orderBy('height') + ->get(); + + if ($banners->isEmpty()) { + throw new RuntimeException('У кампании нет ни одного включённого баннера — клиенту загрузить картинки в мастере.'); + } + + $withoutCreative = $banners->firstWhere('yandex_creative_id', null); + if ($withoutCreative !== null) { + throw new RuntimeException( + "У баннера {$withoutCreative->width}×{$withoutCreative->height} нет номера креатива Яндекса — креативы ещё не загружены в кабинет." + ); } $href = trim((string) ($campaign->landing_url ?? '')); @@ -91,7 +108,16 @@ final class CampaignLauncher ); $adGroupId = $direct->addCpmBannerAdGroup($campaignId, 'Группа #'.$campaign->id, config('services.yandex_direct.region_ids')); $direct->addMediaAudienceTarget($adGroupId, $retId); - $yandexAdId = $direct->addCpmBannerAd($adGroupId, (int) $campaign->yandex_creative_id, $href); + + // Одно объявление на каждый включённый баннер: у медийной кампании нет адаптивного + // креатива, каждый размер блока — своё объявление со своим креативом. + foreach ($banners as $banner) { + $adId = $direct->addCpmBannerAd($adGroupId, (int) $banner->yandex_creative_id, $href); + $banner->update([ + 'yandex_ad_id' => $adId, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + } // 3) Заморозить клиентскую сумму за смету показов в кошельке (Часть A). $this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, $freezeClientRub); @@ -106,7 +132,9 @@ final class CampaignLauncher 'yandex_retargeting_list_id' => $retId, 'yandex_campaign_id' => $campaignId, 'yandex_ad_group_id' => $adGroupId, - 'yandex_ad_id' => $yandexAdId, + // yandex_ad_id на КАМПАНИИ больше не пишем: объявлений теперь набор, их номера + // живут на ad_campaign_banners.yandex_ad_id. Колонка кампании остаётся в базе + // как аварийный ручной путь; продуктового кода, который её читает, нет. 'paid_impressions' => $estImpr, 'status' => AdCampaign::STATUS_PENDING_MODERATION, 'launched_at' => now(), diff --git a/app/app/Services/Advertising/CreativeIdMatcher.php b/app/app/Services/Advertising/CreativeIdMatcher.php new file mode 100644 index 00000000..6b776aab --- /dev/null +++ b/app/app/Services/Advertising/CreativeIdMatcher.php @@ -0,0 +1,57 @@ + $before слепок до загрузки: номер → [ш, в] + * @param array $after слепок после загрузки + * @param list $expectedSizes размеры, которые робот должен был залить + * @return array «300x250» → номер креатива + * + * @throws CreativeMatchFailedException + */ + public function match(array $before, array $after, array $expectedSizes): array + { + $newIds = array_diff_key($after, $before); + + $bySize = []; + foreach ($newIds as $id => [$w, $h]) { + $bySize[$w.'x'.$h][] = (int) $id; + } + + $out = []; + foreach ($expectedSizes as [$w, $h]) { + $key = $w.'x'.$h; + $found = $bySize[$key] ?? []; + + if (count($found) === 0) { + throw new CreativeMatchFailedException( + "После загрузки не появился креатив размера {$key}." + ); + } + if (count($found) > 1) { + throw new CreativeMatchFailedException( + "После загрузки появилось несколько креативов размера {$key} — какой чей, не определить." + ); + } + + $out[$key] = $found[0]; + } + + return $out; + } +} diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php index e3e27270..e5660735 100644 --- a/app/app/Services/Advertising/YandexDirectClient.php +++ b/app/app/Services/Advertising/YandexDirectClient.php @@ -189,6 +189,34 @@ final class YandexDirectClient ]; } + /** + * Creatives.get: слепок картиночных креативов аккаунта — номер → [ширина, высота]. + * + * Нужен для опознания креативов, загруженных роботом в веб-кабинет: имя у всех + * загруженных одинаковое («Новый креатив» — его ставит Яндекс), поэтому опознаём + * разницей слепков «до/после» и сопоставлением по размеру. Загруженные JPG/PNG/GIF + * Яндекс хранит типом HTML5_CREATIVE — проверено живым запросом 27.07.2026. + * + * @return array + */ + public function listImageCreativeIds(): array + { + $res = $this->call('v5/creatives', 'get', [ + 'SelectionCriteria' => [], + 'FieldNames' => ['Id', 'Type', 'Width', 'Height'], + ]); + + $out = []; + foreach ($res['result']['Creatives'] ?? [] as $creative) { + if (($creative['Type'] ?? null) !== 'HTML5_CREATIVE') { + continue; + } + $out[(int) $creative['Id']] = [(int) $creative['Width'], (int) $creative['Height']]; + } + + return $out; + } + /** Campaigns.suspend — пауза показа. */ public function suspendCampaign(int $campaignId): void { diff --git a/app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php b/app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php new file mode 100644 index 00000000..3ac10e08 --- /dev/null +++ b/app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php @@ -0,0 +1,63 @@ +unsignedBigInteger('yandex_creative_id')->nullable()->after('bytes'); + $table->unsignedBigInteger('yandex_ad_id')->nullable()->after('yandex_creative_id'); + $table->string('moderation_status', 16)->default('draft')->after('yandex_ad_id'); + $table->string('moderation_reason')->nullable()->after('moderation_status'); + }); + + // 🔴 На проде crm_supplier_worker и crm_admin_user НЕ BYPASSRLS. Табличные GRANT у + // ad_campaign_banners уже есть у crm_app_user; джобам модерации нужен UPDATE через + // pgsql_supplier. Кросс-тенантный доступ служебных ролей даёт db/03_service_bypass_policies.sql — + // после выката ПЕРЕзапустить его, иначе джоб молча увидит ноль строк. + // + // Гард на pg_roles — на случай окружения, где служебной роли нет: GRANT на несуществующую + // роль роняет миграцию. На liderra и liderra_testing роль есть, гард просто не срабатывает + // (тот же паттерн, что и в 2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php). + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_supplier_worker; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + REVOKE SELECT, UPDATE ON ad_campaign_banners FROM crm_supplier_worker; + END IF; + END + $$; + SQL); + + Schema::table('ad_campaign_banners', function (Blueprint $table) { + $table->dropColumn(['yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason']); + }); + } +}; diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue index e7d79c20..f5553882 100644 --- a/app/resources/js/components/advertising/CampaignWizard.vue +++ b/app/resources/js/components/advertising/CampaignWizard.vue @@ -385,8 +385,8 @@ const bannersApproved = computed(() => bannersApprovedAt.value !== null); /** Сколько размеров уже загружено — определяет, можно ли утверждать набор. */ const uploadedCount = computed(() => bannerSet.value?.slots.filter((s) => s.uploaded).length ?? 0); -/** Человекочитаемый лимит размера файла («150 КБ») — берём из ответа сервера (BannerUploadPolicy::MAX_BYTES). */ -const humanMax = computed(() => `${Math.round((bannerSet.value?.max_bytes ?? 153600) / 1024)} КБ`); +/** Человекочитаемый лимит размера файла («500 КБ») — берём из ответа сервера (BannerUploadPolicy::MAX_BYTES). */ +const humanMax = computed(() => `${Math.round((bannerSet.value?.max_bytes ?? 512000) / 1024)} КБ`); async function loadBanners(): Promise { if (draftId.value === null) return; @@ -405,7 +405,7 @@ async function loadBanners(): Promise { async function uploadSlot(slot: BannerSlot, file: File | null): Promise { if (file === null || draftId.value === null) return; const key = slotKey(slot); - const maxBytes = bannerSet.value?.max_bytes ?? 153600; + const maxBytes = bannerSet.value?.max_bytes ?? 512000; if (file.size > maxBytes) { slotError[key] = `Файл больше ${humanMax.value} — уменьшите вес.`; return; diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php index 7b682b14..7ebc8c53 100644 --- a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php +++ b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Models\AdCampaign; use App\Models\AdCampaignAd; +use App\Models\AdCampaignBanner; use App\Models\AdWallet; use App\Models\AdWalletTransaction; use App\Models\Tenant; @@ -308,9 +309,20 @@ it('returns 422 with a message when launching with a too-small audience', functi 'weekly_budget_rub' => '1000.00', // Медийные поля — заполнены, чтобы дойти именно до проверки аудитории (не оборваться раньше). 'estimated_impressions' => 10000, - 'yandex_creative_id' => 4242, 'landing_url' => 'https://liderra.ru', ]); + // Набор баннеров с номерами креативов — иначе запуск оборвётся на проверке набора, + // а не на аудитории (адаптивного креатива у медийной кампании больше нет). + AdCampaignBanner::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4242, + ]); $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch"); diff --git a/app/tests/Feature/Advertising/BannerYandexIdsTest.php b/app/tests/Feature/Advertising/BannerYandexIdsTest.php new file mode 100644 index 00000000..c458cb3b --- /dev/null +++ b/app/tests/Feature/Advertising/BannerYandexIdsTest.php @@ -0,0 +1,62 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => 'ad-banners/x/300x250.jpg', + 'bytes' => 1000, + 'yandex_creative_id' => 1163586673, + 'yandex_ad_id' => 17786692536, + 'moderation_status' => AdCampaignBanner::MOD_ACCEPTED, + 'moderation_reason' => null, + ]); + + $fresh = $banner->fresh(); + expect($fresh->yandex_creative_id)->toBe(1163586673) + ->and($fresh->yandex_ad_id)->toBe(17786692536) + ->and($fresh->moderation_status)->toBe('ACCEPTED'); +}); + +it('defaults moderation status to draft', function () { + $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', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 728, + 'height' => 90, + 'path' => 'ad-banners/x/728x90.jpg', + 'bytes' => 1000, + ]); + + expect($banner->fresh()->moderation_status)->toBe('draft') + ->and($banner->fresh()->yandex_creative_id)->toBeNull(); +}); diff --git a/app/tests/Feature/Advertising/CampaignLauncherTest.php b/app/tests/Feature/Advertising/CampaignLauncherTest.php index fbd7048d..7caf18dd 100644 --- a/app/tests/Feature/Advertising/CampaignLauncherTest.php +++ b/app/tests/Feature/Advertising/CampaignLauncherTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Exceptions\Advertising\AudienceTooSmallException; use App\Models\AdCampaign; +use App\Models\AdCampaignBanner; use App\Models\AdWallet; use App\Models\AdWalletHold; use App\Models\Tenant; @@ -58,7 +59,25 @@ function seedAudience(AdCampaign $campaign, int $count): void DB::table('ad_campaign_phones')->insert($rows); } -/** Кампания «за показы» в режиме manual с готовым креативом и адресом сайта. */ +/** Кладёт кампании набор баннеров с номерами креативов, как будто робот уже отработал. */ +function seedBanners(AdCampaign $campaign, array $sizesToCreativeId): void +{ + foreach ($sizesToCreativeId as $size => $creativeId) { + [$w, $h] = array_map('intval', explode('x', (string) $size)); + AdCampaignBanner::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => $h, + 'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$size}.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => $creativeId, + ]); + } +} + +/** Кампания «за показы» в режиме manual с адресом сайта; креативы — на баннерах набора. */ function makeImpressionCampaign(int $tenantId, array $overrides = []): AdCampaign { return AdCampaign::create(array_merge([ @@ -71,7 +90,9 @@ function makeImpressionCampaign(int $tenantId, array $overrides = []): AdCampaig 'frequency' => 3, 'frequency_period_days' => 7, 'client_cpm_rub' => '120.00', - 'yandex_creative_id' => 4242, + // Конструктора креативов у Яндекса больше нет: поле кампании — аварийный ручной + // путь, запуск его не читает. Номера креативов живут на баннерах набора. + 'yandex_creative_id' => null, 'landing_url' => 'https://liderra.ru/promo', 'run_days' => 14, ], $overrides)); @@ -86,6 +107,7 @@ it('launches an impression campaign: segment → Direct CPM → freeze budget $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); @@ -95,7 +117,6 @@ it('launches an impression campaign: segment → Direct CPM → freeze budget ->and($campaign->yandex_retargeting_list_id)->toBe(111) ->and($campaign->yandex_campaign_id)->toBe(222) ->and($campaign->yandex_ad_group_id)->toBe(333) - ->and($campaign->yandex_ad_id)->toBe(555) ->and($campaign->paid_impressions)->toBe(100000) // смета = потолок биллинга (Задача 10 fix) ->and($campaign->launched_at)->not->toBeNull(); @@ -147,6 +168,7 @@ it('throws AudienceTooSmallException and does not freeze when audience is under $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 50); + seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(AudienceTooSmallException::class); @@ -180,7 +202,44 @@ it('throws RuntimeException when yandex_direct is disabled and creates nothing', Http::assertNothingSent(); }); -it('throws RuntimeException with a clear message when creative id is missing', function () { +it('creates one ad per included banner and stores the ad id on each banner', function () { + configureYandex(); + Http::fake([ + '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + // Каждый вызов ads.add отдаёт свой номер объявления. + '*/json/v5/ads' => Http::sequence() + ->push(['result' => ['AddResults' => [['Id' => 5551]]]]) + ->push(['result' => ['AddResults' => [['Id' => 5552]]]]), + ]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + + app(CampaignLauncher::class)->launch($campaign); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners)->toHaveCount(2) + ->and($banners[0]->yandex_ad_id)->toBe(5551) + ->and($banners[0]->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION) + ->and($banners[1]->yandex_ad_id)->toBe(5552); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); + + // 2 запроса на сегмент Аудиторий (заливка csv + подтверждение) + retargetinglists + + // campaigns + adgroups + audiencetargets + по одному ads.add на каждый баннер (2) = 8. + Http::assertSentCount(8); +}); + +it('skips banners that are switched off by the client', function () { configureYandex(); fakeYandexEndpoints(); @@ -188,13 +247,57 @@ it('throws RuntimeException with a clear message when creative id is missing', f app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->update(['included' => false]); + + app(CampaignLauncher::class)->launch($campaign); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/ads') + && ($request['params']['Ads'][0]['CpmBannerAdBuilderAd']['Creative']['CreativeId'] ?? null) === 4242; + }); + + $off = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->first(); + expect($off->yandex_ad_id)->toBeNull(); +}); + +it('refuses to launch when a included banner has no creative number yet', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + AdCampaignBanner::where('campaign_id', $campaign->id)->update(['yandex_creative_id' => null]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'креатив'); - $campaign->refresh(); - expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); + Http::assertNothingSent(); + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); +}); + +it('refuses to launch when the campaign has no banners at all', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'баннер'); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); Http::assertNothingSent(); $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); @@ -209,6 +312,7 @@ it('throws RuntimeException with a clear message when landing_url is missing', f app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['landing_url' => null]); + seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'адрес сайта'); @@ -230,6 +334,7 @@ it('throws RuntimeException with a clear message when estimated_impressions is n app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => null]); + seedBanners($campaign, ['300x250' => 4242]); expect(fn () => app(CampaignLauncher::class)->launch($campaign)) ->toThrow(RuntimeException::class, 'смета показов'); @@ -252,6 +357,7 @@ it('does not leak yandex_cost_rub or ad_margin_percent into the serialized campa $campaign = makeImpressionCampaign($tenant->id); seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); app(CampaignLauncher::class)->launch($campaign); diff --git a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php index 7f9b97e3..9d01ae10 100644 --- a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php +++ b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); use App\Events\AdvertisingStopped; use App\Jobs\SyncCampaignModerationJob; use App\Models\AdCampaign; -use App\Models\AdCampaignAd; +use App\Models\AdCampaignBanner; use App\Models\AdWallet; use App\Models\Tenant; use App\Services\Advertising\AdWalletService; @@ -21,7 +21,8 @@ uses(RefreshDatabase::class); // второму соединению до commit'а. uses(SharesSupplierPdo::class); -it('модерация: REJECTED объявление помечает кампанию rejected (passthrough)', function () { +// Единица модерации — баннер набора. Один баннер = «отклонены ВСЕ» → кампания rejected. +it('модерация: единственное объявление REJECTED помечает кампанию rejected (passthrough)', function () { config([ 'services.yandex_direct.enabled' => true, 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', @@ -43,21 +44,24 @@ it('модерация: REJECTED объявление помечает камп 'audience_days' => 10, 'use_uploaded_list' => true, ]); - $ad = AdCampaignAd::create([ + $banner = AdCampaignBanner::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, - 'title' => 'Заголовок', - 'text' => 'Текст объявления', - 'href' => 'https://example.test', + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4555, 'yandex_ad_id' => 555, - 'moderation_status' => 'MODERATION', + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, ]); app(SyncCampaignModerationJob::class)->handle(); - $ad->refresh(); - expect($ad->moderation_status)->toBe('REJECTED'); - expect($ad->moderation_reason)->toBe('Нет гарантий'); + $banner->refresh(); + expect($banner->moderation_status)->toBe('REJECTED'); + expect($banner->moderation_reason)->toBe('Нет гарантий'); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED); @@ -85,20 +89,23 @@ it('модерация: все ACCEPTED переводит кампанию в r 'audience_days' => 10, 'use_uploaded_list' => true, ]); - $ad = AdCampaignAd::create([ + $banner = AdCampaignBanner::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, - 'title' => 'Заголовок', - 'text' => 'Текст объявления', - 'href' => 'https://example.test', + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4555, 'yandex_ad_id' => 555, - 'moderation_status' => 'MODERATION', + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, ]); app(SyncCampaignModerationJob::class)->handle(); - $ad->refresh(); - expect($ad->moderation_status)->toBe('ACCEPTED'); + $banner->refresh(); + expect($banner->moderation_status)->toBe('ACCEPTED'); $campaign->refresh(); expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING); @@ -178,8 +185,9 @@ it('AdvertisingStopped не трогает кампанию чужого тен expect($campaignB->status)->toBe(AdCampaign::STATUS_RUNNING); }); -// ВЫХОД 2 — Яндекс отклонил объявление. Показов не было, деньги не тратились → -// заморозка возвращается клиенту полностью. +// ВЫХОД 2 — Яндекс отклонил ВЕСЬ набор объявлений. Показов не было, деньги не +// тратились → заморозка возвращается клиенту полностью. Живое объявление в наборе +// разморозку не запускает — это проверяет SyncCampaignModerationJobTest. it('ВЫХОД 2: отклонение модерацией снимает заморозку кампании', function () { config([ 'services.yandex_direct.enabled' => true, @@ -205,14 +213,17 @@ it('ВЫХОД 2: отклонение модерацией снимает за 'audience_days' => 10, 'use_uploaded_list' => true, ]); - AdCampaignAd::create([ + AdCampaignBanner::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, - 'title' => 'Заголовок', - 'text' => 'Текст объявления', - 'href' => 'https://example.test', + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4557, 'yandex_ad_id' => 557, - 'moderation_status' => 'MODERATION', + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, ]); $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); diff --git a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php new file mode 100644 index 00000000..46f201eb --- /dev/null +++ b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php @@ -0,0 +1,143 @@ + true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); +} + +/** + * Кампания на модерации с набором баннеров, у каждого свой номер объявления. + * + * @param list $adIds + * @return array{0: AdCampaign, 1: Collection} + */ +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'); +}); diff --git a/app/tests/Unit/Advertising/BannerUploadPolicyTest.php b/app/tests/Unit/Advertising/BannerUploadPolicyTest.php index abc96c59..874f6481 100644 --- a/app/tests/Unit/Advertising/BannerUploadPolicyTest.php +++ b/app/tests/Unit/Advertising/BannerUploadPolicyTest.php @@ -4,8 +4,14 @@ declare(strict_types=1); use App\Services\Advertising\BannerUploadPolicy; -it('defines max bytes, allowed formats and human-readable max size', function () { - expect(BannerUploadPolicy::MAX_BYTES)->toBe(153600) - ->and(BannerUploadPolicy::FORMATS)->toContain('jpg', 'png', 'gif') - ->and(BannerUploadPolicy::humanMaxSize())->toBe('150 КБ'); +it('allows banners up to the yandex limit of 512 kb', function () { + expect(BannerUploadPolicy::MAX_BYTES)->toBe(512000); +}); + +it('formats the limit for humans in kilobytes', function () { + expect(BannerUploadPolicy::humanMaxSize())->toBe('500 КБ'); +}); + +it('accepts the image formats yandex takes', function () { + expect(BannerUploadPolicy::FORMATS)->toBe(['jpg', 'jpeg', 'png', 'gif']); }); diff --git a/app/tests/Unit/Advertising/CreativeIdMatcherTest.php b/app/tests/Unit/Advertising/CreativeIdMatcherTest.php new file mode 100644 index 00000000..7de9b2f9 --- /dev/null +++ b/app/tests/Unit/Advertising/CreativeIdMatcherTest.php @@ -0,0 +1,40 @@ + [300, 250]]; + $after = [111 => [300, 250], 222 => [300, 250], 333 => [728, 90]]; + + $matched = (new CreativeIdMatcher)->match($before, $after, [[300, 250], [728, 90]]); + + expect($matched)->toBe(['300x250' => 222, '728x90' => 333]); +}); + +it('fails when a expected size did not appear', function () { + $before = []; + $after = [222 => [300, 250]]; + + expect(fn () => (new CreativeIdMatcher)->match($before, $after, [[300, 250], [728, 90]])) + ->toThrow(CreativeMatchFailedException::class, '728x90'); +}); + +it('fails when two new creatives share one size', function () { + $before = []; + $after = [222 => [300, 250], 223 => [300, 250]]; + + expect(fn () => (new CreativeIdMatcher)->match($before, $after, [[300, 250]])) + ->toThrow(CreativeMatchFailedException::class, '300x250'); +}); + +it('ignores new creatives of sizes we did not ask for', function () { + $before = []; + $after = [222 => [300, 250], 999 => [160, 600]]; + + $matched = (new CreativeIdMatcher)->match($before, $after, [[300, 250]]); + + expect($matched)->toBe(['300x250' => 222]); +}); diff --git a/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php b/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php index e2c84c30..91852500 100644 --- a/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php +++ b/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php @@ -183,3 +183,30 @@ it('returns null from creative preview when the creative is not found', function expect($preview)->toBeNull(); }); + +it('lists image creative ids with their sizes and ignores video creatives', function () { + Http::fake([ + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 1163441165, 'Type' => 'VIDEO_EXTENSION_CREATIVE', 'Width' => 1080, 'Height' => 1080], + ['Id' => 1163586673, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ['Id' => 1163586680, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + ]); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + // Картиночные креативы Яндекс хранит типом HTML5_CREATIVE даже для загруженных JPG — + // проверено живым запросом 27.07.2026. Видео-креативы в набор попадать не должны. + expect($client->listImageCreativeIds())->toBe([ + 1163586673 => [300, 250], + 1163586680 => [728, 90], + ]); +}); + +it('returns an empty snapshot when the account has no creatives', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => []])]); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + expect($client->listImageCreativeIds())->toBe([]); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 68288174..d8c851cf 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -4,6 +4,34 @@ **Файл схемы:** `schema.sql` (текущая версия — v8.85, консолидированный DDL) +## v9.03 — 27.07.2026 — номера креативов переезжают с кампании на баннер + +`ad_campaign_banners` + `yandex_creative_id`, `yandex_ad_id`, `moderation_status` (default `draft`), +`moderation_reason`. GRANT SELECT, UPDATE для `crm_supplier_worker` (джоб модерации). + +Причина: конструктор креативов Яндекса закрыт 01.06.2026, адаптивного креатива не существует — +медийная кампания состоит из объявления на каждый размер блока со своим креативом +(findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md). Поле `ad_campaigns.yandex_creative_id` +остаётся как аварийный ручной путь и будет снято отдельно. + +Аддитивно, RLS `tenant_isolation` не меняется. Модель `App\Models\AdCampaignBanner`: четыре константы +модерации (`MOD_DRAFT`/`MOD_MODERATION`/`MOD_ACCEPTED`/`MOD_REJECTED`), новые поля в `$fillable`, +`yandex_creative_id`/`yandex_ad_id` в `casts()` → `integer`. Миграция +`app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php`, прогнана на +`liderra_testing`. Тест `app/tests/Feature/Advertising/BannerYandexIdsTest.php`. `schema.sql` не +тронут — по тому же паттерну, что v8.99–v9.02. + +🔴 После выката на прод ПЕРЕзапустить `db/03_service_bypass_policies.sql`. **Это отменяет обратное +указание записи v8.97**, где для `ad_campaign_banners` перезапуск был признан ненужным: тогда таблица +была чисто клиентской, теперь по ней ходит служебная роль `crm_supplier_worker` (джоб модерации), а на +боевом кластере эта роль **не** BYPASSRLS. Без перезапуска джоб молча увидит ноль строк. +Проверка после выката: `SELECT policyname FROM pg_policies WHERE tablename='ad_campaign_banners'` — +ожидаются две строки, `tenant_isolation` и `srv_bypass`. + +⚠️ У `crm_admin_user` привилегий на `ad_campaign_banners` нет (таблица создана позже точечных грантов +v9.01). Пока не нужны — оператор правит `ad_campaigns.yandex_creative_id`. Понадобятся, когда админ-экран +начнёт читать статус модерации по баннерам; решается в задачах запуска и модерации. + ## v9.02 (2026-07-26) — Реклама «за показы», Часть 4 — адрес сайта клиента и id медийного объявления `ad_campaigns` — добавлены две колонки: