From c9406533bf5cbc6ad7ec18f27d79f9cfd74afded 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: Mon, 27 Jul 2026 14:33:02 +0300 Subject: [PATCH] =?UTF-8?q?feat=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B:=20=D1=8F=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D0=B0=D1=82=D0=B8=D0=B2=D0=BE=D0=B2=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все размеры не существует. Медийная кампания состоит из объявления на каждый размер блока со своим креативом, поэтому строка баннера стала единицей размер плюс файл плюс креатив плюс объявление. Что сделано: - у баннера появились номер креатива, номер объявления и статус модерации - кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ - слепок картиночных креативов аккаунта через creatives.get - опознание своих креативов разницей слепков до и после загрузки по размеру - запуск заводит объявление на каждый включённый баннер - модерация считается по каждому объявлению: кампания работает, если принято хотя бы одно, а деньги возвращаются только когда отклонены все Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре. После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе джоб модерации молча увидит ноль баннеров. Co-Authored-By: Claude Opus 5 --- .../CreativeMatchFailedException.php | 14 ++ app/app/Jobs/SyncCampaignModerationJob.php | 104 +++++++------ app/app/Models/AdCampaign.php | 9 ++ app/app/Models/AdCampaignBanner.php | 11 ++ .../Advertising/BannerUploadPolicy.php | 5 +- .../Services/Advertising/CampaignLauncher.php | 36 ++++- .../Advertising/CreativeIdMatcher.php | 57 +++++++ .../Advertising/YandexDirectClient.php | 28 ++++ ..._add_yandex_ids_to_ad_campaign_banners.php | 63 ++++++++ .../components/advertising/CampaignWizard.vue | 6 +- .../AdvertisingCampaignEndpointTest.php | 14 +- .../Advertising/BannerYandexIdsTest.php | 62 ++++++++ .../Advertising/CampaignLauncherTest.php | 118 ++++++++++++++- .../CampaignModerationAndStopTest.php | 59 +++++--- .../SyncCampaignModerationJobTest.php | 143 ++++++++++++++++++ .../Advertising/BannerUploadPolicyTest.php | 14 +- .../Advertising/CreativeIdMatcherTest.php | 40 +++++ .../YandexDirectMediaClientTest.php | 27 ++++ db/CHANGELOG_schema.md | 28 ++++ 19 files changed, 749 insertions(+), 89 deletions(-) create mode 100644 app/app/Exceptions/Advertising/CreativeMatchFailedException.php create mode 100644 app/app/Services/Advertising/CreativeIdMatcher.php create mode 100644 app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php create mode 100644 app/tests/Feature/Advertising/BannerYandexIdsTest.php create mode 100644 app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php create mode 100644 app/tests/Unit/Advertising/CreativeIdMatcherTest.php 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` — добавлены две колонки: