diff --git a/app/app/Models/AdCampaign.php b/app/app/Models/AdCampaign.php index aa4819fb..103febe1 100644 --- a/app/app/Models/AdCampaign.php +++ b/app/app/Models/AdCampaign.php @@ -79,6 +79,7 @@ class AdCampaign extends Model 'yandex_retargeting_list_id', 'yandex_campaign_id', 'yandex_ad_group_id', + 'yandex_creative_id', 'moderation_reason', 'launched_at', ]; @@ -110,6 +111,7 @@ class AdCampaign extends Model 'yandex_retargeting_list_id' => 'integer', 'yandex_campaign_id' => 'integer', 'yandex_ad_group_id' => 'integer', + 'yandex_creative_id' => 'integer', 'launched_at' => 'datetime', ]; } diff --git a/app/config/services.php b/app/config/services.php index 209770a6..50859b08 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -31,6 +31,9 @@ return [ 'enabled' => env('YANDEX_DIRECT_ENABLED', false), // Регионы показа по умолчанию (вся Россия = 225). 'region_ids' => [225], + // Предохранитель: SpendLimit в Яндексе = яндекс-бюджет × множитель (бэкстоп + // от перерасхода, НЕ клиентская цена). + 'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2), ], // Рекламная аудитория кандидатов в ВК (Task 8, 21.07.2026). Программный доступ diff --git a/app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php b/app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php new file mode 100644 index 00000000..e5ec2be6 --- /dev/null +++ b/app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php @@ -0,0 +1,54 @@ +unsignedBigInteger('yandex_creative_id')->nullable()->after('yandex_ad_group_id'); + }); + + // Оператор вписывает номер креатива в админ-экране «Рекламные кампании» под ролью + // crm_admin_user (на Managed-кластере НЕ BYPASSRLS). У неё на ad_campaigns был только + // SELECT (grant_admin_read_advertising) → нужен UPDATE. Даём ТОЧЕЧНО на одну колонку + // (least privilege) — оператор правит только номер креатива, не остальные поля кампании. + // crm_app_user (клиент) и crm_supplier_worker (робот-джобы) уже имеют табличный UPDATE. + // Гард на существование роли: на dev/test роли нет (DB_USERNAME=postgres superuser). + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + REVOKE UPDATE (yandex_creative_id) ON ad_campaigns FROM crm_admin_user; + END IF; + END + $$; + SQL); + + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn('yandex_creative_id'); + }); + } +}; diff --git a/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php new file mode 100644 index 00000000..c7f0b243 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php @@ -0,0 +1,19 @@ +toBeTrue(); +}); + +it('модель AdCampaign позволяет задать и прочитать yandex_creative_id как integer', function () { + $campaign = new AdCampaign(); + $campaign->fill(['yandex_creative_id' => '123456789']); + + expect($campaign->yandex_creative_id)->toBe(123456789) + ->and($campaign->getCasts()['yandex_creative_id'] ?? null)->toBe('integer') + ->and(in_array('yandex_creative_id', $campaign->getFillable(), true))->toBeTrue(); +}); diff --git a/app/tests/Unit/Advertising/YandexDirectConfigTest.php b/app/tests/Unit/Advertising/YandexDirectConfigTest.php index d8b568fb..f56bf878 100644 --- a/app/tests/Unit/Advertising/YandexDirectConfigTest.php +++ b/app/tests/Unit/Advertising/YandexDirectConfigTest.php @@ -10,3 +10,9 @@ it('defaults yandex_direct base_url to the sandbox host', function () { expect(config('services.yandex_direct.base_url'))->toContain('api-sandbox.direct.yandex.com') ->and(config('services.yandex_direct.enabled'))->toBeFalse(); }); + +it('exposes cpm media defaults and keeps switch off', function () { + expect(config('services.yandex_direct.enabled'))->toBeFalse() + ->and(config('services.yandex_direct.region_ids'))->toBe([225]) + ->and(config('services.yandex_direct.spend_limit_guard_multiplier'))->toBe(1.2); +}); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 4b5ab31b..2e762e36 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -4,6 +4,22 @@ **Файл схемы:** `schema.sql` (текущая версия — v8.85, консолидированный DDL) +## v9.01 (2026-07-26) — Реклама «за показы», Часть 4 — номер адаптивного креатива Яндекса на кампании + +`ad_campaigns` — добавлена колонка `yandex_creative_id` (`BIGINT UNSIGNED` nullable) — номер +(CreativeId) адаптивного креатива в Яндекс.Директе; один креатив покрывает все размеры баннера, +поэтому номер хранится на кампании, а не на `ad_campaign_banners`. Пока не заполняется никем — +позже впишет оператор (админ-экран) или робот-креативщик. Аддитивно, RLS `tenant_isolation` +не меняется. GRANT: `crm_app_user` (клиент) и `crm_supplier_worker` (робот-джобы) уже имеют +табличный `UPDATE ON ad_campaigns` (покрывает новую колонку). Для оператора добавлен **точечный** +`GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user` (least privilege: у admin +на таблице был только SELECT) — с гардом на существование роли (dev/test = postgres superuser). +Модель `App\Models\AdCampaign`: `yandex_creative_id` в `$fillable` и `casts()` → `integer`. +Миграция `app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php`, +прогнана на `liderra_testing`. Тест +`app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php`. `schema.sql` не тронут — +по тому же паттерну, что и соседние миграции v8.99/v9.00. + ## v9.00 (2026-07-26) — Реклама «за показы»: два режима сбора аудитории + клиентская цена + наша наценка `ad_campaigns` — добавлено пять колонок: `mode` (`VARCHAR(10) NOT NULL DEFAULT 'auto'`, `'auto'| diff --git a/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4-v2.md b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4-v2.md new file mode 100644 index 00000000..f9e5ab6f --- /dev/null +++ b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4-v2.md @@ -0,0 +1,106 @@ +# Промт перезапуска v2 — реклама «за показы», Часть 4 + робот-креативщик + +> Скопируй текст ниже целиком в новую сессию (после /compact). + +--- + +Продолжаем рекламу Лидерры «за показы» в Яндексе. Ты в git worktree +`.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. Прод liderra.ru — БОЕВОЙ, +живые деньги: ничего на бой/в gitea без явного «go» владельца; БД по умолчанию только чтение; +наценка/`yandex_cost_rub`/`ad_margin_percent` — НИКОГДА в клиентском JSON; коммиты только по escape, +paren-free, `LEFTHOOK_EXCLUDE=larastan`; субагенты для git — Sonnet, не Haiku, и они НЕ коммитят. +Владелец — не программист, говори простым русским. Рубильник `YANDEX_DIRECT_ENABLED` — ВЫКЛ, +`base_url` — песочница; реальных кампаний в боевом кабинете НЕ создавать до отдельного «go». + +## Большие решения этой сессии (главное) +1. **Песочницы Директа НЕТ** (у аккаунта полный доступ — песочница только под тестовый). Поэтому код + пишем по документации + полностью проверяем `Http::fake`-тестами; живую сверку делаем ОДИН раз при + go-live (Задача 9), под присмотром владельца. +2. **Баннер-креатив через API не залить** (Яндекс принимает картинки только в веб-конструкторе). + Но **один АДАПТИВНЫЙ креатив покрывает все 15 размеров** (не 15 креативов!). Номер креатива + (`CreativeId`) хранится НА КАМПАНИИ (`ad_campaigns.yandex_creative_id`). +3. **Путь:** клиент грузит 1 картинку в портал → оператор/робот делает ОДИН адаптивный креатив в + Яндексе → номер в портал → запуск автоматический. Клиент Яндекс НЕ трогает. Портал может показать + клиенту превью готового креатива (`creatives.get` → `PreviewUrl`/`ThumbnailUrl`/`IsAdaptive`). +4. **Три части проекта (согласовано с владельцем), порядок ЯДРО→РОБОТ→ВЫБОР:** + - **ЯДРО (Часть B) — автозапуск через API** = задачи Части 4. ← делаем сейчас. + - **РОБОТ (Часть C) — робот-креативщик** (Playwright RPA): сам заходит в конструктор Яндекса, грузит + картинку, делает адаптивный креатив, забирает номер; споткнулся (капча/вход/смена вёрстки) → + ПИСЬМО-АЛЯРМ оператору со скриншотом. **Делать ПО ОБРАЗЦУ телеграм-бота:** + `docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md` (та же архитектура: свой + персистентный вход на сервере, keep-alive, watchdog+письмо, режимы черновик/боевой, потолок + трат). Робот сначала получает короткий свой дизайн-спек → одобрение владельца → стройка. + - **ВЫБОР (Часть A) — выбор клиенту в мастере:** «Яндекс сам соберёт все 15 из 1 картинки» + (адаптив) ИЛИ «дам свои 15» (фиксированные креативы, тяжелее); + понятное объяснение, что делает + ИИ Яндекса. Один кривой размер чинится сдвигом кадра в Смарт-центре или заменой этого размера + своей картинкой. + +## Контракт медийного API (сверен по документации, детали — в findings) +Файл: `docs/superpowers/findings/2026-07-26-direct-media-api-contract.md`. Кратко: +- `campaigns.add` `CpmBannerCampaign`: `BiddingStrategy {Search:{BiddingStrategyType:"SERVING_OFF"}, + Network:{BiddingStrategyType:"CP_MAXIMUM_IMPRESSIONS", CpMaximumImpressions:{AverageCpm, SpendLimit, + StartDate, EndDate, AutoContinue}}}`, `FrequencyCap:{Impressions, PeriodDays 1..30}`. Деньги — + МИКРОСЫ (₽ × 1 000 000). +- `adgroups.add` `CpmBannerKeywordsAdGroup: {}` (пустой) + `Name`/`CampaignId`/`RegionIds`. Аудитория — + ОТДЕЛЬНЫМ `audiencetargets.add {AdGroupId, RetargetingListId}` (RetargetingListId из + `RetargetingLists.add`, метод `addRetargetingList` уже есть). +- `ads.add` `CpmBannerAdBuilderAd {Creative:{CreativeId}, Href}` (НЕ image-hash). +- `creatives.get` FieldNames: `Id,Type,PreviewUrl,ThumbnailUrl,IsAdaptive,Width,Height`. +- Отчёт `CAMPAIGN_PERFORMANCE_REPORT`: `Impressions,Cost`. + +## Деньги (важно) +Наценка — `ad_settings.ad_margin_percent` (дефолт 40), модель **«клиент × (1 − наценка/100)»** — ТА ЖЕ, +что в `CampaignImpressionCharger` (Часть 6). НЕ `AdMarkup`-делением `÷(1+30%)`. `AverageCpm` в Директ = +`effectiveCpm() × (1 − margin/100) × 1e6`. `SpendLimit` = `estimated_impressions/1000 × яндекс_cpm × +services.yandex_direct.spend_limit_guard_multiplier (1.2)`, микросы. Всё bcmath, микросы — целые. + +## СДЕЛАНО этой сессией (⚠️ НЕ ЗАКОММИЧЕНО, лежит в worktree) +- **Задача 2 (config):** `config/services.php` → `yandex_direct.spend_limit_guard_multiplier` (1.2) + + тест `tests/Unit/Advertising/YandexDirectConfigTest.php`. ✓ +- **Миграция:** `2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php` — nullable bigint + `ad_campaigns.yandex_creative_id` + точечный `GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO + crm_admin_user` (гард на роль). Модель `AdCampaign`: поле в `$fillable`+`casts()=integer`. Тест + `tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php`. Запись `db/CHANGELOG_schema.md` + v9.01 (в WORKTREE — не в основной папке!). rls-reviewer пройден. ✓ +- Тесты: `tests/Feature/Advertising` 140/140, `tests/Unit/Advertising` 22/22 зелёные. +- План обновлён: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md`. +- 🔴 **Коммита ещё НЕ было** — владелец не давал «go». Первый чистый кусок (config+миграция+доки) + готов к коммиту; спросить владельца: коммитить кусками или одним коммитом в конце. + +## ОСТАЛОСЬ по ядру (делать через subagent-driven-development, TDD, субагенты НЕ коммитят) +- **Задачи 3–6:** медийные методы в `app/app/Services/Advertising/YandexDirectClient.php` + + `Http::fake`-тесты (`tests/Unit/Advertising/YandexDirectMediaClientTest.php`): + `addCpmBannerCampaign(...)`, `addCpmBannerAdGroup(...)`, `addMediaAudienceTarget(adGroupId, + retargetingListId)` (без ContextBid — стратегия CPM), `addCpmBannerAd(adGroupId, creativeId, href)`, + `getCreativePreview(creativeId)`. Старые клик-методы пока НЕ удалять. +- **Задача 7:** переписать `CampaignLauncher::launch()` под показы: цепочка `addRetargetingList → + addCpmBannerCampaign → addCpmBannerAdGroup → addMediaAudienceTarget → addCpmBannerAd(campaign + .yandex_creative_id)`; наценка «минус 40%»; заморозка в клиентских ₽; ОДНО объявление; если + `yandex_creative_id` пуст → понятная ошибка (не 500); money-leak тест (нет + `yandex_cost_rub`/`ad_margin_percent` в JSON). Строить из `ad_campaign_banners` только проверку + «есть включённые+утверждённые». +- **Задача 8:** контроллер `submit`/`launch` + чистка легаси (`weekly_budget_rub`, `click_bid_rub`, + `AdMarkup`, старые `addCampaign(TextCampaign)`/`addTextAd`/`addAudienceTarget(ContextBid)` если не + нужны) + **админ-поле ввода номера креатива** (`AdminAdvertisingController` + + `AdminAdvertisingView.vue`, роль crm_admin_user) + `getCampaignSpend` под показы (Impressions). +- **Задача 9 (при go-live, отдельный «go»):** один контролируемый пробный запуск в боевом кабинете + под присмотром, немедленная остановка, сверить реальные поля с findings. Рубильник НЕ включать сам. +- **Задача 10:** финальное ревью (`superpowers:requesting-code-review`), `composer test`, хэндофф + + чек-лист go-live. +Потом — **РОБОТ (Часть C)** и **ВЫБОР (Часть A)**. + +## Как работаю / грабли окружения +- subagent-driven-development: субагент пишет тест+код, НЕ коммитит; я ревьюю diff, гоняю тесты, + коммичу по escape владельца. RLS-правки → прогонять агента `rls-reviewer`. +- Worktree: свой `composer install` (vendor есть). **Larastan в worktree молча падает exit-1** + (junction-vendor, известная граблина) → `--error-format=json` или опираться на Pest. Тесты — на + `liderra_testing`. Ключевые файлы: `YandexDirectClient.php`, `CampaignLauncher.php`, + `AdvertisingCampaignController.php`, `AdminAdvertisingController.php`, `AdCampaign.php` (уже с + `yandex_creative_id`), `AdCampaignBanner.php` (15 размеров, флаг `included`), `BannerSizes.php` + (15 размеров), `CampaignImpressionCharger.php` (модель наценки), `config/services.php`. +- 🔴 CHANGELOG схемы правь в **worktree** `db/CHANGELOG_schema.md`, НЕ в основной папке репо (в этой + сессии субагент ошибся — правил основную; починено). +- Память: `project-reklama-modul-vykat-2026-07-25` (доступ Директа + токен + грабля srv_bypass). + +Сначала прочитай план и findings, затем продолжай по задачам ядра. Отчитывайся владельцу простым +языком; на необратимое/коммит — спрашивай «go». diff --git a/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4.md b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4.md new file mode 100644 index 00000000..2cb74472 --- /dev/null +++ b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4.md @@ -0,0 +1,68 @@ +# Промт перезапуска — реклама «за показы», Часть 4 (связь с Директом) + +> Скопируй текст ниже целиком в новую сессию. + +--- + +Продолжаем рекламный модуль Лидерры «за показы» (Яндекс). Ты в worktree +`.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy` (HEAD `5a4c0e02`). +Прод liderra.ru — БОЕВОЙ, живые деньги: ничего на бой/в gitea без явного «go» владельца; +БД по умолчанию только чтение; наценка/`yandex_cost_rub`/`ad_margin_percent` — НИКОГДА клиенту; +коммиты только по escape, paren-free, `LEFTHOOK_EXCLUDE=larastan`; субагенты для git — Sonnet, НЕ Haiku, +и они НЕ коммитят. Владелец — не программист, говори простым русским. + +## Что уже сделано (26.07.2026) +- **Доступ к API Директа ОДОБРЕН** (кабинет `sasha261185` → Настройки API → Мои заявки: + «одобрена», доступ «полный», «Программный доступ: открыт», 32000 баллов). ClientID приложения + `5919f23ef9114baf9d14b88cd929a8ae`. +- **OAuth-токен создан и установлен на бой:** прод `app/.env` → `YANDEX_DIRECT_TOKEN` (bearer, ~363 дня). + Живой `campaigns.get` к `api.direct.yandex.com` → 200 OK (вернул 2 ручные кампании владельца). + 🔴 Рубильник `YANDEX_DIRECT_ENABLED` = **ВЫКЛ**, `base_url` = **песочница** — портал сам в Директ + НЕ ходит. Токен разово светился в переписке прошлой сессии — при паранойе перевыпустить. +- **Почти весь модуль показов уже готов** (Части 1–3, 5, 6): модель `AdCampaign` на показы + (`frequency`, `estimated_impressions`, `paid_impressions`, `delivered_impressions`, + `client_cpm_rub`, `yandex_cost_rub`, `mode` auto|manual, `effectiveCpm()`), смета, баннеры, + мастер, списание по факту (`CampaignImpressionCharger` / `ChargeCampaignSpendJob`). +- **Не сделана только Часть 4** — реальный запуск в Директ. Сейчас `CampaignLauncher`/`YandexDirectClient` + строят ТЕКСТОВУЮ кампанию «за клики»; `submit()` просто ставит статус `queued` (в коде комментарий + «реальный запуск доделает Часть 4»). Медийных методов в `YandexDirectClient` нет. + +## Твоя задача: выполнить Часть 4 по готовому плану +План (полный, с задачами и тестами): +`docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md` +Дизайн (источник истины, лежит в ОСНОВНОЙ папке репо, не в worktree): +`docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md` + +Суть: переписать запуск с текстовой кампании (клики) на **медийную `CpmBannerCampaign` (за показы)**: +сегмент Аудиторий → ретаргетинг-условие → медийная кампания (`CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, +`SpendLimit`=бэкстоп) → группа `CpmBannerKeywordsAdGroup` (автотаргетинг OFF, без ключей, единственное +условие = наш сегмент) → медийные объявления `CpmBannerAd` → заморозка клиентских денег. В Директ уходит +`client_cpm × (1 − ad_margin_percent/100)` (дефолт наценки 40%). Всё за рубильником. + +Исполнять через **`superpowers:subagent-driven-development`** (TDD, субагенты НЕ коммитят; ты ревьюишь diff, +гоняешь тесты, коммит по «go»). Начни с **Задачи 1 — спайк в песочнице**: живой вызов сверяет ТОЧНЫЕ поля +медийного API (документация — лишь гипотеза). + +🔴 **Блокер:** песочница Директа для `sasha261185` не инициализирована — `campaigns.get` к +`api-sandbox.direct.yandex.com` даёт **err 513 «логин не подключён»**. Задача 1 Шаг 1 — включить песочницу +(браузер `sandbox.direct.yandex.ru` под тем же логином ИЛИ первый create). У владельца есть залогиненный +кабинет Яндекса в Playwright-браузере, доступ к кабинету он дал. **Боевой рубильник НЕ включать**, реальных +кампаний в боевом кабинете НЕ создавать — обкатка только в песочнице; выкат на бой — отдельным «go». + +## Доступы / инфраструктура +- Прод SSH: `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137`, приложение `/var/www/liderra/app`. + `.env` читается только под www-data → команды через `sudo -u www-data bash -c "cd /var/www/liderra/app && ..."`. + `config:cache` — ТОЛЬКО под www-data (квирк 107). tinker — код через stdin, не `--execute` (квирк 110). + SSH рвётся на длинных/параллельных командах — одна команда на вызов (квирк 109). +- Проверка API на бою (read-only, безопасно): скрипт `campaigns.get` в tinker (см. прошлую сессию). +- Windows worktree: свой `composer install`; larastan `--error-format=json` (память `feedback-worktree-laravel-windows`). +- Память: `project-reklama-modul-vykat-2026-07-25.md` (доступ Директа + токен + грабля srv_bypass на RLS-таблицах). + +## Ключевые файлы Части 4 +- `app/app/Services/Advertising/YandexDirectClient.php` (+медийные методы, убрать клик-методы) +- `app/app/Services/Advertising/CampaignLauncher.php` (переписать `launch()` под показы) +- `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` (`submit`/`launch`) +- `app/app/Models/AdCampaign.php` (убрать легаси `weekly_budget_rub`/`click_bid_rub`) +- `app/config/services.php` (параметры медийной стратегии; рубильник НЕ включать) + +Сначала прочитай план и дизайн, затем выполняй по задачам. По ходу — отчитывайся владельцу простым языком. diff --git a/docs/superpowers/findings/2026-07-26-direct-media-api-contract.md b/docs/superpowers/findings/2026-07-26-direct-media-api-contract.md new file mode 100644 index 00000000..eefb209a --- /dev/null +++ b/docs/superpowers/findings/2026-07-26-direct-media-api-contract.md @@ -0,0 +1,175 @@ +# Контракт медийного API Яндекс.Директ v5 (за показы) — сверено по документации 26.07.2026 + +> Источник: официальная документация `yandex.ru/dev/direct/doc/ru/*` (WebFetch страниц методов +> `campaigns.add` / `get-cpm-banner-campaign`, `adgroups.add`, `audiencetargets.add`, `ads.add`, +> `creatives.add`). Песочницы для полного доступа нет → **живьём НЕ проверено**; окончательное +> подтверждение — Задача 9 (контролируемый пробный запуск в боевом кабинете при go-live). +> Это источник истины для Задач 3–6 плана Части 4. + +## Общее +- **Деньги — микросы:** все ставки/цены = целое = (сумма в ₽ × 1 000 000), в валюте рекламодателя. + Подтверждено для `AverageCpm`, `SpendLimit`, `DailyBudget.Amount` и в справочнике валют. +- JSON API v5, эндпоинты `/json/v5/`. + +--- + +## 1. `campaigns.add` — CpmBannerCampaign (медийная кампания за показы) + +**CpmBannerCampaignAddItem:** +| Поле | Тип | Обяз. | Примечание | +|---|---|---|---| +| `BiddingStrategy` | CpmBannerCampaignStrategyAdd | **да** | Search + Network | +| `FrequencyCap` | FrequencyCapSetting | нет | ограничение частоты показов | +| `Settings` | array CpmBannerCampaignSetting | нет | YES/NO флаги | +| `CounterIds` | ArrayOfInteger | нет | счётчики Метрики | +| `VideoTarget`, `ExcludedSitesForVideoAds` | — | нет | нам не нужны | + +Плюс общекампанийные поля метода `campaigns.add` (обязательные): `Name`, `StartDate`; +опционально `EndDate`, `ClientInfo` и др. + +**BiddingStrategy (CpmBannerCampaignStrategyAdd):** +- `Search`: `{ "BiddingStrategyType": "SERVING_OFF" }` — **обязательно** (поиск для медийной выключен). +- `Network`: `{ "BiddingStrategyType": , "<НужныйОбъектСтратегии>": {...} }` — **обязательно**. + - enum Network: `MANUAL_CPM` | `CP_DECREASED_PRICE_FOR_REPEATED_IMPRESSIONS` | + `WB_DECREASED_PRICE_FOR_REPEATED_IMPRESSIONS` | `CP_MAXIMUM_IMPRESSIONS` | + `WB_MAXIMUM_IMPRESSIONS` | `CP_AVERAGE_CPV` | `WB_AVERAGE_CPV`. + +**Вложенные объекты стратегий (Network):** +| BiddingStrategyType | Объект | Поля | +|---|---|---| +| `CP_MAXIMUM_IMPRESSIONS` | `CpMaximumImpressions` | `AverageCpm`, `SpendLimit`, `StartDate`, `EndDate`, `AutoContinue` | +| `WB_MAXIMUM_IMPRESSIONS` | `WbMaximumImpressions` | `AverageCpm`, `SpendLimit` | +| `MANUAL_CPM` | `ManualCpm` | `WeeklySpendLimit` (ставка задаётся на группу через `NetworkBid`) | + +**✅ Наш выбор — `CP_MAXIMUM_IMPRESSIONS` / `CpMaximumImpressions`:** максимум показов при заданной +средней цене за 1000 показов (`AverageCpm`), за период (`StartDate`/`EndDate`), с потолком расхода +(`SpendLimit` = бюджет за период) и `AutoContinue` (YES/NO — продлевать ли). Совпадает с исходной +гипотезой плана; подходит и manual-режиму (`run_days`), и auto. (Ранний общий веб-поиск ошибочно +утверждал, что этих имён нет — детальная страница `get-cpm-banner-campaign` их подтверждает.) + +**FrequencyCap (FrequencyCapSetting):** +- `Impressions` (int, **обяз.**) — макс. показов одному пользователю за период. +- `PeriodDays` (int, nillable, **обяз.**) — 1..30; `null` = весь срок кампании. + +**Деньги:** `AverageCpm`, `SpendLimit` — микросы (₽ × 1e6). + +--- + +## 2. `adgroups.add` — CpmBannerKeywordsAdGroup (группа на аудиторию) + +| Поле | Тип | Обяз. | Примечание | +|---|---|---|---| +| `Name` | string 1..255 | **да** | | +| `CampaignId` | long | **да** | id медийной кампании из шага 1 | +| `RegionIds` | array long (≥1) | **да** | регионы; `[0]` — вся страна/без ограничения | +| `CpmBannerKeywordsAdGroup` | структура | **да** | создаётся **пустой**: `{}` | + +- Автотаргетинг для этого типа группы **не применяется** (поля нет) — специально выключать нечего. +- Ключевые слова в этот метод **не передаются**. +- **Аудитория/ретаргетинг цепляется ОТДЕЛЬНЫМ вызовом** `audiencetargets.add` (см. §3), не внутри группы. + +--- + +## 3. Привязка сегмента — `RetargetingLists.add` → `audiencetargets.add` + +- Сегмент Аудиторий → **условие ретаргетинга** через `RetargetingLists.add` + (у нас уже есть `YandexDirectClient::addRetargetingList` — вернёт `RetargetingListId`). +- Затем `audiencetargets.add` вешает условие на медийную группу: + +**AudienceTarget:** +| Поле | Тип | Обяз. | Примечание | +|---|---|---|---| +| `AdGroupId` | long | **да** | id группы из §2 | +| `RetargetingListId` | long | **да** | из `RetargetingLists.add`; уникален в рамках группы | +| `ContextBid` | long | нет | ставка; по умолчанию — мин. ставка. ⚠️ см. go-live | +| `StrategyPriority` | enum | нет | по умолчанию `NORMAL`; только для автостратегий | + +--- + +## 4. `ads.add` — CpmBannerAdBuilderAd (медийное объявление) + +**🔴 Отличие от гипотезы плана:** нет типа `CpmBannerAd` с сырым image-hash. Медийное объявление — +только **`CpmBannerAdBuilderAd`**, ссылается на **готовый креатив по `CreativeId`**: + +```json +"CpmBannerAdBuilderAd": { + "Creative": { "CreativeId": }, // обязательно + "Href": "", // обязательно — ссылка на сайт + "TrackingPixels": { "Items": [""] } // опц., ≤2 +} +``` +- `AdGroupId` (обяз.) — привязка к группе. +- Документация про `Creative`: *«Креатив, загруженный в веб-интерфейсе или созданный в конструкторе + креативов»*. + +**🔴🔴 ГЛАВНАЯ ЗАГВОЗДКА (нужно решение владельца):** креатив медийного баннера **нельзя загрузить +картинкой через API**. `creatives.add` в API v5 умеет создавать **только** `VideoExtensionCreative` +(видеодополнения по `VideoId`) — **не** графические баннеры. Значит `CreativeId` для нашего баннера +получается **только** после создания креатива в **веб-конструкторе Яндекса** (руками), потом читается +через `creatives.get`. Полностью автоматический путь «портал → API → живой медийный баннер» упирается +в ручной шаг создания креатива (аналогично ситуации с Telegram/МТС «руками»). + +Варианты (решает владелец): +1. **Полу-ручной:** креативы заводятся в конструкторе Яндекса один раз, `CreativeId` записывается в + портал (на баннер/на клиента), дальше запуск автоматический. Просто, но нужен ручной шаг на баннеры. +2. **Разведать альтернативу:** HTML5-креатив / другой способ загрузки, другой формат объявления — + требует доп. проверки, не факт что даёт чистый CPM на нашу аудиторию. + +### 🔎 Результат разведки альтернативы (26.07.2026, прямое чтение `creatives.get`/`creatives.add`) +Типы креативов в API (по `creatives.get`): `IMAGE_CREATIVE`, `HTML5_CREATIVE`, `VIDEO_EXTENSION_CREATIVE`, +`CPC_VIDEO_CREATIVE`, `CPM_VIDEO_CREATIVE`, `SMART_CREATIVE` — т.е. графические и HTML5 креативы как +объекты **существуют** и читаются по `CreativeId`. **НО** `creatives.add` принимает **только** +`VideoExtensionCreative` (видеодополнения) — создать через API `IMAGE_CREATIVE`/`HTML5_CREATIVE` +**нельзя**. Отдельного API у Конструктора креативов нет. + +Альтернативный путь `AdImages.add` → `AdImageHash` **работает**, но графические объявления с image-hash +идут в **«Единую перформанс-кампанию»** (перформанс/клики), а НЕ в медийную `CpmBannerCampaign` «за +показы»; старые типы кампаний с 22.05 через API создавать запрещено. То есть авто-заливка картинки +существует только для **другого продукта** с иной логикой показа/оплаты — не для нашей модели показов. + +**Вывод:** для модели «за показы» (Части 1–6) автоматической заливки баннера через API нет. +Единственный путь под нашу модель — **полу-ручной (вариант 1):** креатив-баннер создаётся в +веб-конструкторе Яндекса, портал хранит `CreativeId`, запуск дальше автоматический. Переход на +перформанс-кампанию = отказ от модели показов и переделка Частей 1–6 (большая работа, вне Части 4). + +### ✅ Сколько креативов нужно: ОДИН адаптивный на кампанию (не 15) +Справка Яндекса: в Конструкторе креативов из **одной** картинки делается **адаптивный креатив**, +который сам подстраивается под все размеры блоков РСЯ — *«достаточно создать один адаптивный… +система автоматически создаст объявления всех нужных размеров… под любой рекламный блок»*. Правило: +**одно объявление = один креатив**; с адаптивным креативом **одно** `CpmBannerAdBuilderAd` покрывает +все форматы. Значит ручной шаг пути А = **один креатив на кампанию** (один `CreativeId`), не 15. + +**Следствие для дизайна (уточнение к §4):** `yandex_creative_id` хранить **на кампании** +(`ad_campaigns`), а НЕ на каждой из 15 строк `ad_campaign_banners`. Наши 15 нарезанных размеров +(`BannerSizes`, `BannerGenerator`) остаются для превью/утверждения в портале; в Директ уходит один +адаптивный креатив. Запуск создаёт **одно** медийное объявление `CpmBannerAdBuilderAd` с этим +`CreativeId`. ⚠️ Проверить при go-live: адаптивный креатив реально доступен для медийной (CPM) +кампании и создаётся из одной картинки в конструкторе. + +--- + +## 5. Отчёт по показам (для списания, Часть 6) + +- Отчёт `CAMPAIGN_PERFORMANCE_REPORT`, поля `Impressions` и `Cost` (async TSV). +- ⚠️ Существующий `getCampaignSpend` заточен под клики — при переходе на показы поправить `FieldNames` + (`Impressions`), сверить единицу `Cost` (микросы). Согласовать с `ChargeCampaignSpendJob`. + +--- + +## ⚠️ Проверить живьём при go-live (Задача 9) +1. Нужен ли `ContextBid` для `audiencetargets.add` под стратегией `CP_MAXIMUM_IMPRESSIONS` (или ставка + полностью управляется `AverageCpm` кампании). +2. Обязательность и формат `StartDate`/`EndDate`/`AutoContinue` внутри `CpMaximumImpressions` + (даты — строка `YYYY-MM-DD`; `AutoContinue` — `YES`/`NO`). +3. Семантика `SpendLimit` для `Cp`-стратегии (бюджет за период кампании, не недельный). +4. Как получаем `CreativeId` (ручной конструктор? см. §4) — влияет на дизайн загрузки баннеров. +5. Формат отчёта по показам: точные `FieldNames`, единица `Cost`, async-поллинг. + +## Что совпало с планом / что поправить в Задачах +- ✅ Стратегия `CP_MAXIMUM_IMPRESSIONS` / `CpMaximumImpressions {AverageCpm, SpendLimit, StartDate, + EndDate, AutoContinue}` — как в плане. +- ✅ `FrequencyCap {Impressions, PeriodDays}`, деньги в микросах — как в плане. +- ✅ Группа `CpmBannerKeywordsAdGroup: {}` пустая; аудитория — отдельным `audiencetargets.add`. +- 🔴 Задача 6 плана: объявление — `CpmBannerAdBuilderAd {Creative.CreativeId, Href}`, **не** + `CpmBannerAd {ImageHash}`. Загрузка баннера картинкой через API невозможна — нужен `CreativeId` + из конструктора. Метод клиента вместо `addCpmBannerAd(imageHash)` → `addCpmBannerAd(creativeId, href)`. diff --git a/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md new file mode 100644 index 00000000..654f925d --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md @@ -0,0 +1,261 @@ +# План Части 4 — связь с Директом: медийная кампания «за показы» + +> **Для исполнителя:** ОБЯЗАТЕЛЬНЫЙ СУБ-СКИЛ — `superpowers:subagent-driven-development` (TDD, субагент НЕ коммитит; контроллер ревьюит diff, гоняет тесты, коммит по «go»). Шаги — чекбоксами. + +**Goal:** Переписать запуск рекламной кампании в Яндекс.Директ с текстовой кампании «за клики» на **медийную кампанию «за показы» (`CpmBannerCampaign`)**, строго на собранный сегмент, под уже готовую модель показов (`frequency`, `estimated_impressions`, `client_cpm_rub`, `ad_margin_percent`). Обкатка — в песочнице Директа; боевой рубильник `yandex_direct.enabled` остаётся ВЫКЛ до отдельного «go» владельца. + +**Architecture:** `CampaignLauncher.launch()` оркеструет цепочку через `YandexDirectClient`: сегмент Аудиторий → ретаргетинг-условие → медийная кампания (`CpmBannerCampaign`, стратегия сети `CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, `SpendLimit` = бэкстоп расхода Яндекса) → группа `CpmBannerKeywordsAdGroup` (автотаргетинг OFF, без ключей, единственное условие = наш сегмент) → медийные объявления (`CpmBannerAd`) на модерацию → заморозка клиентских денег в кошельке. Деньги: клиент платит `client_cpm_rub`; в Директ уходит `× (1 − ad_margin_percent/100)` (наценка клиенту не видна). Всё за рубильником. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Pest 4 (`Http::fake`), bcmath (scale 2, микросы — целые). Yandex Direct API v5 (JSON). + +**Money-инварианты (боевой прод!):** `yandex_cost_rub` / `ad_margin_percent` / расход Яндекса — НИКОГДА в клиентском JSON (уже в `$hidden`; добавить тест). Заморозка при запуске — по оценке сметы в клиентских ₽, идемпотентна. Списание по факту — уже готово (Часть 6, `ChargeCampaignSpendJob`). + +--- + +## Контекст: что уже есть, что меняем + +**Готово (не трогаем логику, только опираемся):** +- Модель `AdCampaign` уже на показы: `mode` (auto|manual), `frequency`, `frequency_period_days`, `estimated_impressions`, `paid_impressions`, `delivered_impressions`, `client_cpm_rub`, `yandex_cost_rub` (в `$hidden`), `effectiveCpm()`, `snapshot_from/to`, `run_days`. Легаси-поля `weekly_budget_rub`, `click_bid_rub` ещё в fillable — вычистим (Задача 8). +- `CampaignAudienceBuilder` — собирает телефоны по режиму (auto: окно `audience_days`; manual: диапазон дат + свой список). Отдаёт массив телефонов. +- `CampaignEstimateService` / `audienceSize` — смета `показы × cpm/1000`. +- `CampaignImpressionCharger` (Часть 6) — списание по факту показов, пишет `yandex_cost_rub = списано × (1 − margin/100)`. +- `YandexAudienceClient` — создание сегмента Аудиторий (upload + confirm). +- `YandexDirectClient` — ЕСТЬ: `addRetargetingList`, `suspendCampaign`, `resumeCampaign`, `getAdsModeration`, `getCampaignSpend`. НЕТ медийных методов. +- Контроллер: `submit()` → статус `queued` (комментарий «реальный запуск доделает Часть 4»); `launch()` → зовёт старый `CampaignLauncher->launch()` (текстовая кампания за клики). +- Токен Директа установлен на бой (`YANDEX_DIRECT_TOKEN`), доступ к API **одобрен** (полный). `enabled=false`, `base_url=api-sandbox…`. + +**Меняем:** `YandexDirectClient` (+медийные методы, старый `addCampaign(TextCampaign)`/`addAudienceTarget(ContextBid)`/`addTextAd` → медийные), `CampaignLauncher.launch()` (пересчёт под показы), контроллер `submit/launch` (флоу запуска), чистка легаси клик-полей, сверка наценки 30%→`ad_margin_percent` 40%. + +**🔴 Песочницы нет (решено 26.07.2026):** для аккаунта `sasha261185` с уже одобренным **полным** доступом отдельная песочница Директа недоступна (`campaigns.get` к `api-sandbox` → err 513 «логин не подключён»; песочница выдаётся только под *тестовый* доступ, который мы переросли). Решение владельца: **код Части 4 пишем строго по актуальной документации API v5 + полностью проверяем `Http::fake`-тестами (без Яндекса); живую сверку делаем ОДИН раз при go-live** — контролируемый пробный запуск в боевом кабинете под присмотром владельца, с немедленной остановкой. Задача 1 — сверка контракта медийного API по документации (не живьём). **Рискованные места (точные имена enum/полей, где микросы) помечаем в findings как «проверить при go-live».** + +--- + +## File Structure + +- Modify: `app/app/Services/Advertising/YandexDirectClient.php` — +медийные методы, заменить клик-методы. +- Modify: `app/app/Services/Advertising/CampaignLauncher.php` — переписать `launch()` под показы. +- Modify: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` — флоу `submit`/`launch`. +- Modify: `app/config/services.php` — параметры медийной стратегии (регион, дефолты), рубильник. +- Modify: `app/app/Models/AdCampaign.php` — убрать легаси клик-поля из fillable/casts. +- Create: `app/tests/Unit/Advertising/YandexDirectMediaClientTest.php` — юнит на построение JSON медийных методов (Http::fake). +- Modify/Create: `app/tests/Feature/Advertising/CampaignLauncherTest.php` — запуск медийной цепочки (Http::fake), money-инварианты. +- Modify: `app/tests/Feature/Advertising/CampaignSubmitTest.php` — обновить под новый флоу submit/launch (если меняется). +- Create: `docs/superpowers/findings/2026-07-XX-direct-media-api-sandbox-verified.md` — фиксация реальных полей API из Задачи 1. + +**Нужна одна аддитивная миграция** (вскрыто Задачей 1 + вопросом владельца про 15 размеров): +- `ad_campaigns.yandex_creative_id` (nullable bigint) — **номер адаптивного креатива Яндекса на + кампанию** (один адаптивный креатив покрывает все 15 размеров, см. findings §4). Хранить на + кампании, НЕ на `ad_campaign_banners`. +- `ad_campaign_banners` GRANT сейчас `SELECT, INSERT, DELETE` для `crm_app_user` — если номер вводит + клиент, нужен UPDATE; но т.к. номер на кампании и вводит **оператор в админке** (роль + `crm_admin_user`/`crm_app_admin`), проверить, что у admin-роли есть UPDATE на `ad_campaigns` для + этого поля. Миграция аддитивна: только `ADD COLUMN` + при необходимости GRANT. Обязательно + `db/CHANGELOG_schema.md` + прогон `rls-reviewer`. Помнить про `srv_bypass` (память + `project-reklama-modul-vykat-2026-07-25`): новая колонка на существующей таблице — политики не + трогаем, но если admin-экран читает под `crm_admin_user` — сверить доступ. + +Ввод номера креатива оператором — маленькое поле в админ-экране «Рекламные кампании» +(`AdminAdvertisingController` + `AdminAdvertisingView.vue`); по умолчанию вводит оператор (не клиент). + +--- + +## Task 1: Сверка контракта медийного API v5 по документации (песочницы нет) + +**Files:** Create `docs/superpowers/findings/2026-07-26-direct-media-api-contract.md` (фиксация контракта из документации). Кода приложения не трогаем. + +> Не TDD — это сбор фактов из документации. Цель — получить максимально точный контракт медийного API, на который лягут Задачи 3–6. Живьём НЕ проверяем (песочницы нет; живая сверка — Задача 9 при go-live). + +- [ ] **Шаг 1.** Из **актуальной официальной документации** Яндекс.Директ API v5 (`yandex.ru/dev/direct/doc`) выписать точные структуры запросов/ответов: + - `campaigns.add` c `CpmBannerCampaign` (стратегия сети `CP_MAXIMUM_IMPRESSIONS` → `CpMaximumImpressions {AverageCpm, SpendLimit, StartDate, EndDate, AutoContinue}`; `Search {BiddingStrategyType: SERVING_OFF}`; `FrequencyCap {Impressions, PeriodDays}`). Точные обязательные поля, единицы (микросы vs рубли), допустимые enum сети. + - `adgroups.add` c `CpmBannerKeywordsAdGroup` (`Autotargeting.State: OFF`, `Keywords: []`, точное имя условия ретаргетинга + поля). + - Как сегмент Аудиторий связывается с медийной группой (`RetargetingLists.add` → `RetargetingListId`; `audiencetargets.add` → `AudienceTargetId`; что именно принимает `CpmBannerKeywordsAdGroup`). + - `adimages` upload (медийный формат/версия эндпоинта) + `ads.add` c `CpmBannerAd` (`Creative {Type: IMAGE, ...}`, `Href`). Точное имя хэша (`AdImageHash` vs `ImageHash`) и допустимые размеры медийных креативов. + - Отчёт по показам: `CAMPAIGN_PERFORMANCE_REPORT` — поля `Impressions`/`Cost`, тип отчёта, async/TSV, единица `Cost`. +- [ ] **Шаг 2.** Записать в findings-файл: точные JSON-контракты (запрос+ответ по докам), список принятых размеров баннеров, обязательные поля, где микросы, формат отчёта по показам. Это — источник истины для Задач 3–6. +- [ ] **Шаг 3.** Отдельным разделом «⚠️ Проверить при go-live» перечислить всё, что из документации неоднозначно (спорные имена enum/полей, единицы) — эти места проверяем живьём в Задаче 9. + +**Verify:** findings-файл содержит по каждому методу (campaigns.add / adgroups.add / audience-привязка / ads.add / отчёт) точную структуру из документации + список «проверить при go-live». + +--- + +## Task 2: Config — параметры медийной стратегии + рубильник + +**Files:** Modify `app/config/services.php`; Test `app/tests/Unit/Advertising/YandexDirectConfigTest.php`. + +- [ ] **Шаг 1: Тест.** Дополнить `YandexDirectConfigTest`: + +```php +it('exposes cpm media defaults and keeps switch off', function () { + expect(config('services.yandex_direct.enabled'))->toBeFalse() + ->and(config('services.yandex_direct.region_ids'))->toBe([225]) + ->and(config('services.yandex_direct.spend_limit_guard_multiplier'))->toBe(1.2); +}); +``` + +- [ ] **Шаг 2: Запуск.** `composer test -- --filter=YandexDirectConfigTest` → FAIL (нет ключа). +- [ ] **Шаг 3: Код.** В `services.yandex_direct` добавить `'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2)` (SpendLimit Яндекса = яндекс-бюджет × множитель — бэкстоп от перерасхода, не клиентская цена). `enabled` и `base_url` НЕ трогаем (остаются off/sandbox). +- [ ] **Шаг 4: Запуск.** Тест PASS. +- [ ] **Шаг 5: Commit** (по «go» контроллера). + +--- + +## Task 3: `YandexDirectClient::addCpmBannerCampaign()` + +**Files:** Modify `app/app/Services/Advertising/YandexDirectClient.php`; Test `app/tests/Unit/Advertising/YandexDirectMediaClientTest.php`. + +> Точную структуру взять из findings Задачи 1. Ниже — гипотеза по документации. + +- [ ] **Шаг 1: Тест** (Http::fake, проверяем ТЕЛО запроса и разбор ответа): + +```php +it('строит CpmBannerCampaign с CP_MAXIMUM_IMPRESSIONS, FrequencyCap и SpendLimit', function () { + Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 777]]]])]); + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + + $id = $client->addCpmBannerCampaign( + name: 'Лидерра #5', startDate: '2026-08-01', endDate: '2026-08-14', + averageCpmMicros: 72_000_000, spendLimitMicros: 90_000_000, + frequencyImpressions: 3, frequencyPeriodDays: 14, + ); + + expect($id)->toBe(777); + Http::assertSent(function ($req) { + $b = $req->data(); + $cpm = $b['params']['Campaigns'][0]['CpmBannerCampaign']; + return $b['method'] === 'add' + && $cpm['BiddingStrategy']['Search']['BiddingStrategyType'] === 'SERVING_OFF' + && $cpm['BiddingStrategy']['Network']['BiddingStrategyType'] === 'CP_MAXIMUM_IMPRESSIONS' + && $cpm['BiddingStrategy']['Network']['CpMaximumImpressions']['AverageCpm'] === 72_000_000 + && $cpm['BiddingStrategy']['Network']['CpMaximumImpressions']['SpendLimit'] === 90_000_000 + && $cpm['FrequencyCap']['Impressions'] === 3 + && $cpm['FrequencyCap']['PeriodDays'] === 14; + }); +}); +``` + +- [ ] **Шаг 2: Запуск** → FAIL (метода нет). +- [ ] **Шаг 3: Код.** Добавить метод `addCpmBannerCampaign(string $name, string $startDate, string $endDate, int $averageCpmMicros, int $spendLimitMicros, int $frequencyImpressions, int $frequencyPeriodDays): int`, тело по контракту Задачи 1, возвращает `AddResults[0].Id`. Через приватный `call()`. +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 4: `YandexDirectClient::addCpmBannerAdGroup()` — группа с условием = сегмент, автотаргетинг OFF + +**Files:** Modify `YandexDirectClient.php`; Test дополнить `YandexDirectMediaClientTest.php`. + +- [ ] **Шаг 1: Тест** — проверить, что группа создаётся с `CpmBannerKeywordsAdGroup`, `Autotargeting.State=OFF`, `Keywords=[]`, `RetargetingCondition` с нашим `AudienceTargetId`, регионы из конфига. (Точные имена — из Задачи 1.) +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** `addCpmBannerAdGroup(int $campaignId, string $name, array $regionIds, int $audienceTargetId): int`. +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 5: Привязка сегмента — ретаргетинг/`AudienceTarget` для медийной группы + +**Files:** Modify `YandexDirectClient.php`; Test дополнить. + +> Задача 1 покажет: используется ли существующий `addRetargetingList` + `AudienceTargets.add` (получить `AudienceTargetId`), или условие вешается прямо в группе. Реализовать по факту. + +- [ ] **Шаг 1: Тест** — метод возвращает `AudienceTargetId`/`RetargetingListId`, который принимает группа (Задача 4). +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** При необходимости `addMediaAudienceTarget(...)`/переиспользовать `addRetargetingList`. Без клик-ставки `ContextBid` (медийная не по кликам). +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 6: `addCpmBannerAd()` — медийное объявление по `CreativeId` + +**Files:** Modify `YandexDirectClient.php`; Test дополнить. + +> 🔴 **Уточнено Задачей 1:** медийное объявление — только `CpmBannerAdBuilderAd {Creative {CreativeId}, Href}`, НЕ image-hash. Загрузка баннера картинкой через API невозможна (`creatives.add` умеет только видеодополнения); `CreativeId` берётся из веб-конструктора Яндекса. Метод клиента принимает `creativeId`, а не hash. **Откуда портал берёт `CreativeId` — решение владельца (полу-ручной шаг), фиксируется до этой задачи.** + +- [ ] **Шаг 1: Тест** — `addCpmBannerAd(int $adGroupId, int $creativeId, string $href): int` шлёт `CpmBannerAdBuilderAd {Creative {CreativeId}, Href}`, возвращает Id (Http::fake). +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** Метод по контракту §4 findings. Старые `uploadAdImage(v501)`/`addTextAd` — пометить на удаление (Задача 8), если не нужны. +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +> Если владелец выберет альтернативу загрузки баннеров (HTML5 и т.п.) — уточнить метод перед этой задачей. + +--- + +## Task 7: `CampaignLauncher::launch()` — переписать под показы (деньги + цепочка) + +**Files:** Modify `app/app/Services/Advertising/CampaignLauncher.php`; Test `app/tests/Feature/Advertising/CampaignLauncherTest.php`. + +> **Источник наценки — `ad_settings.ad_margin_percent` (дефолт 40), модель «клиент × (1 − наценка/100)»** — ТА ЖЕ, что в `CampaignImpressionCharger` (Часть 6), НЕ `AdMarkup`-делением `÷(1+markup 30%)`. Иначе разойдутся запуск и списание. `AdMarkup` в `launch()` больше не используем. +> +> **Объявление — ОДНО** `CpmBannerAdBuilderAd` с `ad_campaigns.yandex_creative_id` (адаптивный креатив покрывает все размеры). Строим из баннеров `ad_campaign_banners` только проверку «есть включённые и утверждённые»; сам креатив — один номер с кампании. Если `yandex_creative_id` пуст → бросить понятную ошибку «у кампании не указан номер креатива Яндекса — оператору оформить креатив и вписать номер» (не падать 500). + +- [ ] **Шаг 1: Тест** (Http::fake всей цепочки Аудитории+Директа) — проверить money-инварианты и порядок вызовов: + - аудитория < 100 → `AudienceTooSmallException` (как сейчас); + - нет `yandex_creative_id` → понятное исключение (не запускаем); + - `AverageCpm` в Директ = `effectiveCpm() × (1 − ad_margin_percent/100) × 1e6` (микросы, bcmath) — т.е. **яндекс-цена, не клиентская**; + - `SpendLimit` = яндекс-бюджет (`estimated_impressions/1000 × яндекс_cpm`) × `spend_limit_guard_multiplier`, микросы; + - `FrequencyCap` = `frequency` / `frequency_period_days`; + - `StartDate/EndDate` — из режима (manual: `run_days` от сегодня; auto: разумный дефолт); + - стратегия `CP_MAXIMUM_IMPRESSIONS` / `CpMaximumImpressions {AverageCpm, SpendLimit, StartDate, EndDate, AutoContinue}`, Search `SERVING_OFF`; + - заморозка кошелька — в КЛИЕНТСКИХ ₽ по оценке сметы (round up), идемпотентно; + - на кампании проставлены `yandex_segment_id`, `yandex_retargeting_list_id`, `yandex_campaign_id`, `yandex_ad_group_id`, `status = pending_moderation`, `launched_at`; создано ОДНО медийное объявление (`yandex_ad_id` где хранить — на кампании или отд. поле, решить в коде), `moderation_status = MODERATION`. + - **Money-leak тест:** сериализация кампании в JSON НЕ содержит `yandex_cost_rub`/`ad_margin_percent`. +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** Переписать `launch()`: наценка через `ad_settings.ad_margin_percent` (модель «минус»); убрать `AdMarkup`/`weekly_budget_rub`/`click_bid_rub`/`ContextBid`(клик); собрать медийную цепочку через новые методы клиента (Задачи 3–6): `addRetargetingList` → `addCpmBannerCampaign` → `addCpmBannerAdGroup` → `addMediaAudienceTarget` → `addCpmBannerAd(adGroupId, yandex_creative_id, href)`. Все деньги — bcmath, микросы — целые. `href` — сайт клиента (из настроек/кампании). +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 8: Флоу `submit`/`launch` в контроллере + чистка легаси клик-кода + +**Files:** Modify `AdvertisingCampaignController.php`, `AdCampaign.php`; Test `CampaignSubmitTest.php` (+ endpoint-тест launch). + +- [ ] **Шаг 1: Тест.** Зафиксировать флоу: `submit` (готовность) → `queued`; фактический `launch` идёт **только при `yandex_direct.enabled=true`** (иначе 409 «Директ выключен» — как сейчас в `CampaignLauncher`). Проверить, что при `enabled=false` submit не падает и деньги не морозятся. Обновить существующие submit-тесты, если сигнатура/тексты меняются. +- [ ] **Шаг 2: Запуск** → FAIL/адаптация. +- [ ] **Шаг 3: Код.** Согласовать `submit`↔`launch`: submit оставить как «готово к запуску»; `launch` (ручной/джобом) выполняет медийную цепочку под рубильником. Убрать из `AdCampaign` fillable/casts легаси `weekly_budget_rub`, `click_bid_rub`; удалить старые методы клиента `addCampaign(TextCampaign)`/`addTextAd`/`addAudienceTarget(ContextBid)`, если не используются; удалить мёртвый `AdMarkup` клик-путь. Сверить: `getCampaignSpend` возвращает **показы** (Impressions), а не клики — поправить `FieldNames`/парсинг под отчёт показов (согласовать с `ChargeCampaignSpendJob` Части 6). +- [ ] **Шаг 4: Запуск** — все тесты рекламы зелёные. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 9: Живая сверка при go-live — контролируемый пробный запуск в боевом кабинете (ОТДЕЛЬНЫЙ «go») + +**Files:** дополнить findings-файл Задачи 1. + +> Песочницы нет → живьём проверяем ТОЛЬКО в боевом кабинете, поэтому этот шаг выполняется НЕ во время разработки, а отдельным контролируемым сеансом под присмотром владельца, по его явному «go». К этому моменту весь код и `Http::fake`-тесты (Задачи 2–8) уже зелёные. + +- [ ] **Шаг 1.** По отдельному «go» владельца: переключить стенд на боевой (`base_url=api.direct.yandex.com`, рубильник ВКЛ временно), прогнать `CampaignLauncher->launch()` на ОДНОЙ тестовой кампании с крошечным сегментом и минимальным `SpendLimit`. Проверить: 200 OK на каждом методе, id проставлены, кампания создалась медийная (`Type=CPM_BANNER_CAMPAIGN`). +- [ ] **Шаг 2.** Немедленно остановить/архивировать кампанию (`suspendCampaign`) — до начала открутки показов. Деньги не тратятся, пока показов нет. Свериться: реальные имена enum/полей совпали с findings Задачи 1 (закрыть список «проверить при go-live»); поправить код при расхождении. +- [ ] **Шаг 3.** Проверить отчёт по показам (или зафиксировать, что за пробный запуск статистики нет — тогда `ChargeCampaignSpendJob` покрыт юнитом на fake-отчёте). Вернуть рубильник/`base_url` в исходное безопасное состояние до отдельного решения о полном go-live. Записать результат в findings. + +--- + +## Task 10: Финальное ревью + подготовка к выкату (без включения на бою) + +- [ ] **Шаг 1.** `superpowers:requesting-code-review` по всему diff Части 4 (money-leak, RLS не затронут, нет мёртвого клик-кода, идемпотентность заморозки). +- [ ] **Шаг 2.** Полный прогон: `composer test -- --filter=Advertising` + затронутые фронт-специи (если менялись) зелёные; `composer stan` 0. +- [ ] **Шаг 3.** Обновить хэндофф `docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-*.md`: Часть 4 готова к бою, чек-лист go-live (переключить `YANDEX_DIRECT_BASE_URL`→`api.direct.yandex.com`, `YANDEX_DIRECT_ENABLED`→true, `config:cache` под www-data, один контролируемый тестовый запуск под присмотром). +- [ ] **Шаг 4.** НЕ выкатывать, НЕ включать рубильник. Отчитаться владельцу, ждать «go». + +--- + +## 🔴 Мины (перечитать перед стартом) + +- **Боевой прод — деньги.** Весь код за рубильником `yandex_direct.enabled=false` до go-live. Песочницы нет → живая сверка (Задача 9) идёт в боевом кабинете ОДНИМ контролируемым пробным запуском под присмотром владельца, с немедленной остановкой; переключение `base_url`/рубильника — отдельным «go». Никаких реальных РАБОТАЮЩИХ кампаний без ведома владельца. +- **Наценка/`yandex_cost_rub`/`ad_margin_percent`** — НИКОГДА в клиентском JSON (тест в Задаче 7). +- **Микросы — целые** (1 ₽ = 1 000 000); все деньги bcmath, никакого float-округления в расчёте сумм. +- **Точные поля медийного API** — из Задачи 1 (сверка по документации), окончательно подтверждаются живьём в Задаче 9 при go-live. Спорные места помечены «проверить при go-live». +- **Наценка 30% vs 40%:** старый `CampaignLauncher` брал `markup_percent` 30%; новый флоу — `ad_margin_percent` 40% (Часть 5d). Не оставить два источника наценки. +- **Тройная страховка перерасхода:** `FrequencyCap` + фикс. размер сегмента + джоб-стоп по оплаченным показам (Часть 6) + `SpendLimit` в Директе. +- **Windows worktree:** свой `composer install`; larastan в worktree — `--error-format=json` (см. память `feedback-worktree-laravel-windows`). Коммиты — `LEFTHOOK_EXCLUDE=larastan`, paren-free, по «go». + +--- + +## Self-review (проведён при написании плана) + +- **Покрытие дизайна §5/§6:** цепочка Директа (сегмент→ретаргетинг→медийная→группа→объявления→заморозка) — Задачи 3–7; деньги/наценка — Задача 7; чистка клик-полей — Задача 8; отчёт по показам — Задача 8; сверка API по документации — Задача 1; живая сверка при go-live — Задача 9. ✅ +- **Placeholder-scan:** точные поля API вынесены в Задачу 1 (сверка по документации) — это не placeholder, а сбор контракта; Задачи 3–6 ссылаются на него, окончательное подтверждение живьём — Задача 9. ✅ +- **Type-consistency:** методы клиента (`addCpmBannerCampaign`/`addCpmBannerAdGroup`/`addMediaAudienceTarget`/`addCpmBannerAd`) названы единообразно и используются в Задаче 7. ✅ +- **Открытый риск (решён 26.07.2026):** песочницы для полного доступа нет → живая сверка перенесена в Задачу 9 (контролируемый пробный запуск в боевом кабинете при go-live, под присмотром владельца). Разработка и все `Http::fake`-тесты (Задачи 2–8) от Яндекса не зависят.