diff --git a/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md b/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md new file mode 100644 index 00000000..d1ffee1b --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md @@ -0,0 +1,2110 @@ +# Отказы модерации Яндекса — окно передачи. План работ + +> **Для исполнителя:** ОБЯЗАТЕЛЬНЫЙ СУБ-СКИЛ — `superpowers:subagent-driven-development` +> или `superpowers:executing-plans`. Шаги помечены чекбоксами `- [ ]`. + +**Цель:** клиент видит, за что Яндекс отклонил его рекламу, отвечает прямо в портале, +а робот носит правки и документы в кабинет. Владелец в цепочке не участвует. + +**Замысел:** [2026-07-28-yandex-otkazy-okno-peredachi-design.md](../specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md) +— читать целиком перед началом. Приёмочный лист там, §8. + +**Устройство:** новая таблица `ad_campaign_messages` — лента сообщений по кампании. +Пояснения Яндекса кладёт туда существующий джоб опроса модерации, ответы клиента — +новые ручки портала, доклады робота — служебный канал. Оживление отклонённой кампании +не создаёт второго пути запуска: кампания возвращается в черновик, дальше работает +существующий `CampaignLauncher`. + +**Стек:** PHP 8.3 / Laravel 13 / PostgreSQL 16 с RLS / Pest 4 / Vue 3 + Vuetify 3. +Робот — Node 24 ESM, `node --test`, Playwright. + +--- + +## 🔴 Правила, которые действуют на каждом шаге + +1. **Сначала тест, который краснеет.** Убедиться, что красный по правильной причине, + а не из-за опечатки в самом тесте. +2. **Защиту проверять вырезанием:** временно сломать боевой код, убедиться, что тест + покраснел, вернуть. 🪤 Перед мутацией копировать файл в scratchpad и возвращать + оттуда — `git checkout -- <файл>` сносит и саму правку. +3. **После каждой задачи — полный прогон обоих наборов**, по одному процессу за раз: + - портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + - робот: `cd bots/yandex-creatives && node --test` +4. 🔴 **Мест снятия заморозки денег ровно четыре.** Проверять после каждой задачи, + которая трогает деньги: `grep -rn -- "->release(" app/app` — денежных должно быть + четыре (`AdvertisingCampaignController`, `SyncCampaignModerationJob`, + `PauseCampaignsOnAdStop`, `CampaignImpressionCharger`), остальные — замки `Cache::lock`. +5. **Миграция → `rls-reviewer` + запись в `db/CHANGELOG_schema.md`.** +6. **База для тестов только `liderra_testing_reklama`.** Никогда не `liderra` и не + `liderra_testing`. +7. **Коммит — по эскейпу владельца.** Сообщение без круглых скобок, только явные пути, + `LEFTHOOK_EXCLUDE=larastan`. Новые файлы требуют предварительного `git add`. +8. **Pint только по своим файлам:** `cd app && ./vendor/bin/pint <файл> <файл>`. + +--- + +## Разбивка на заходы + +Владелец просил куски по 200–300 тысяч токенов с точками компакта. Каждый заход +заканчивается: полный прогон → обновление файла хода работ → коммит → **запись состояния +в файл**. После этого можно спокойно делать компакт: всё, что нужно следующей сессии, +лежит на диске, а не в переписке. + +| Заход | Задачи | Что готово к концу | Робот нужен | +|---|---|---|---| +| **1** | 1–4 | Пояснения Яндекса ложатся в ленту, клиенту летят письмо и колокольчик | нет | +| **2** | 5–8 | Клиент читает ленту и отвечает с файлом; кусок 1 закрыт целиком | нет | +| **3** | 9–12 | «Исправить» работает, кампания оживает | нет | +| **4** | 13–16 | Робот читает кабинет и носит документы | да | + +🔴 **Заход 4 не начинать, пока не снята живая разметка кабинета** — см. Задачу 13. + +--- + +## Карта файлов + +**Заход 1–2 (переписка):** + +- Создать `app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php` — таблица, RLS, гранты. +- Создать `app/app/Models/AdCampaignMessage.php` — модель одного сообщения. +- Создать `app/app/Services/Advertising/CampaignMessageService.php` — единственное место, + где сообщения создаются: дедуп + уведомления. +- Создать `app/app/Mail/AdModerationMessageMail.php` + `app/resources/views/mail/ad-moderation-message.blade.php`. +- Изменить `app/app/Jobs/SyncCampaignModerationJob.php` — класть пояснение Яндекса в ленту. +- Создать `app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php` — список, ответ, файл. +- Изменить `app/routes/web.php` — три маршрута в существующую группу `/api/advertising`. +- Создать `app/resources/js/components/advertising/CampaignMessages.vue` — лента. +- Изменить `app/resources/js/api/advertising.ts`, `app/resources/js/components/advertising/CampaignReportDialog.vue`, + `app/resources/js/components/advertising/CampaignList.vue`. + +**Заход 3 (оживление):** + +- Изменить `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` — ручка «Исправить» + узкое исключение в замке. +- Создать `app/app/Services/Advertising/CampaignReviveService.php` — вся логика возврата в черновик. +- Изменить `app/app/Services/Advertising/YandexDirectClient.php` — `deleteAds()`. + +**Заход 4 (робот):** + +- Создать `app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php`. +- Изменить `app/app/Models/AdCreativeJob.php`, `app/app/Services/Advertising/CreativeJobService.php`, + `app/app/Http/Controllers/Api/CreativeRobotController.php`. +- Изменить `bots/yandex-creatives/src/runner.js`, `src/cabinet.js`, `src/portal.js`. + +--- + +# ЗАХОД 1 — Яндекс говорит, клиент узнаёт + +## Задача 1: таблица сообщений + +**Файлы:** + +- Создать: `app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php` +- Создать: `app/app/Models/AdCampaignMessage.php` +- Тест: `app/tests/Feature/Advertising/CampaignMessageServiceTest.php` +- Изменить: `db/CHANGELOG_schema.md` + +- [ ] **Шаг 1: тест, который краснеет** + +Создать `app/tests/Feature/Advertising/CampaignMessageServiceTest.php`: + +```php +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + return [$tenant, $campaign]; +} + +it('текст сообщения хранится целиком, длиннее 255 знаков в том числе', function () { + [$tenant, $campaign] = messageCampaign(); + + // Модератор перечисляет претензии списком — 255 знаков не хватает. Именно предел + // колонки moderation_reason уже валил обход модерации целиком. + $long = str_repeat('Причина отказа очень подробная. ', 40); + + $msg = AdCampaignMessage::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, + 'body' => $long, + ]); + + expect(mb_strlen($msg->refresh()->body))->toBe(mb_strlen($long)); +}); +``` + +- [ ] **Шаг 2: убедиться, что тест падает** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageService` + +Ожидаемо: FAIL — класс `App\Models\AdCampaignMessage` не существует. + +- [ ] **Шаг 3: миграция** + +Создать `app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php`: + +```php +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete(); + $table->foreignId('banner_id')->nullable()->constrained('ad_campaign_banners')->nullOnDelete(); + $table->string('author', 16); // yandex | client | system + $table->text('body'); + $table->string('file_path', 512)->nullable(); + $table->string('file_name', 255)->nullable(); + $table->unsignedInteger('file_size')->nullable(); + $table->string('file_mime', 128)->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id', 'id']); + }); + + DB::statement('ALTER TABLE ad_campaign_messages ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_campaign_messages FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_messages'); + DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_messages USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + // Гранты внутри DO $$ с гардом на роль: на dev/test приложение ходит суперпользователем + // postgres, ролей там нет, и голый GRANT уронил бы миграцию. + DB::statement(<<<'SQL' + DO $$ + BEGIN + -- crm_app_user — клиентский портал: читает ленту и пишет ответы клиента. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT, INSERT ON ad_campaign_messages TO crm_app_user; + END IF; + + -- crm_supplier_worker — под этой ролью бежит SyncCampaignModerationJob + -- (он перечисляет кампании через соединение pgsql_supplier). Именно он + -- кладёт в ленту пояснения Яндекса. UPDATE и DELETE не нужны: лента + -- только пополняется, сообщения не правятся и не стираются. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT, INSERT ON ad_campaign_messages TO crm_supplier_worker; + END IF; + END + $$; + SQL); + + // Нумератор — отдельный объект со своими правами: GRANT INSERT на таблицу НЕ даёт + // права взять следующий номер, и на бою INSERT упал бы с «permission denied for + // sequence». На dev дырка невидима — там суперпользователь. + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'ad_campaign_messages_id_seq' AND c.relkind = 'S' AND n.nspname = 'public' + ) THEN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_app_user; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_supplier_worker; + END IF; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + Schema::dropIfExists('ad_campaign_messages'); + } +}; +``` + +- [ ] **Шаг 4: модель** + +Создать `app/app/Models/AdCampaignMessage.php`: + +```php + 'integer', + 'campaign_id' => 'integer', + 'banner_id' => 'integer', + 'file_size' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} +``` + +- [ ] **Шаг 5: промигрировать тестовую базу и убедиться, что тест зелёный** + +Запустить: + +```bash +cd app && DB_DATABASE=liderra_testing_reklama php artisan migrate +cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageService +``` + +Ожидаемо: PASS. + +- [ ] **Шаг 6: rls-reviewer + CHANGELOG** + +Позвать агента `rls-reviewer` по новой миграции. Дождаться GO. **Отчёт проверить самому +по коду** — в этой ветке ревьюер уже давал верный вердикт с неверным обоснованием. + +Дописать в `db/CHANGELOG_schema.md` запись **v9.10**: таблица `ad_campaign_messages`, +RLS `tenant_isolation`, гранты `crm_app_user` и `crm_supplier_worker` (SELECT, INSERT) +плюс нумератор; 🔴 после выката перезапустить `db/03_service_bypass_policies.sql`. + +- [ ] **Шаг 7: коммит** (по эскейпу владельца) + +```bash +cd app && ./vendor/bin/pint app/Models/AdCampaignMessage.php database/migrations/2026_07_28_100000_create_ad_campaign_messages.php +git add app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php app/app/Models/AdCampaignMessage.php app/tests/Feature/Advertising/CampaignMessageServiceTest.php +LEFTHOOK_EXCLUDE=larastan git commit app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php app/app/Models/AdCampaignMessage.php app/tests/Feature/Advertising/CampaignMessageServiceTest.php db/CHANGELOG_schema.md -m "feat реклама за показы: лента сообщений по кампании — таблица и модель" +``` + +--- + +## Задача 2: сервис сообщений и защита от дублей + +**Файлы:** + +- Создать: `app/app/Services/Advertising/CampaignMessageService.php` +- Тест: `app/tests/Feature/Advertising/CampaignMessageServiceTest.php` (дописать) + +- [ ] **Шаг 1: тесты, которые краснеют** + +Дописать в `app/tests/Feature/Advertising/CampaignMessageServiceTest.php`: + +```php +use App\Models\AdCampaignBanner; +use App\Services\Advertising\CampaignMessageService; + +it('пояснение Яндекса ложится в ленту', function () { + [$tenant, $campaign] = messageCampaign(); + + app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не соответствует требованиям'); + + $messages = AdCampaignMessage::where('campaign_id', $campaign->id)->get(); + expect($messages)->toHaveCount(1) + ->and($messages->first()->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX) + ->and($messages->first()->body)->toBe('Изображение не соответствует требованиям'); +}); + +/** + * Опрос модерации бежит по расписанию. Пока Яндекс не передумал, он присылает ОДНУ И ТУ ЖЕ + * причину при каждом обходе. Без защиты лента за сутки превратится в сотню одинаковых строк, + * и клиенту прилетит сотня писем. + */ +it('тот же текст по тому же баннеру второй раз в ленту не попадает', function () { + [$tenant, $campaign] = messageCampaign(); + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, + 'path' => 'ad-banners/a/300x250.jpg', + ]); + + $service = app(CampaignMessageService::class); + $service->postFromYandex($campaign, $banner->id, 'Одно и то же'); + $second = $service->postFromYandex($campaign, $banner->id, 'Одно и то же'); + + expect($second)->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +/** Другой размер блока — другая беда, её надо показать отдельно. */ +it('тот же текст по ДРУГОМУ баннеру в ленту попадает', function () { + [$tenant, $campaign] = messageCampaign(); + $slot = [ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'bytes' => 1000, 'included' => true, + ]; + $a = AdCampaignBanner::create($slot + ['width' => 300, 'height' => 250, 'path' => 'ad-banners/a/300x250.jpg']); + $b = AdCampaignBanner::create($slot + ['width' => 728, 'height' => 90, 'path' => 'ad-banners/a/728x90.jpg']); + + $service = app(CampaignMessageService::class); + $service->postFromYandex($campaign, $a->id, 'Одно и то же'); + $service->postFromYandex($campaign, $b->id, 'Одно и то же'); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2); +}); + +/** Яндекс передумал и написал другое — это новость, её показываем. */ +it('изменившийся текст по тому же баннеру в ленту попадает', function () { + [$tenant, $campaign] = messageCampaign(); + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, + 'path' => 'ad-banners/a/300x250.jpg', + ]); + + $service = app(CampaignMessageService::class); + $service->postFromYandex($campaign, $banner->id, 'Первая претензия'); + $service->postFromYandex($campaign, $banner->id, 'Вторая претензия'); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2); +}); + +it('пустой текст в ленту не кладём', function () { + [, $campaign] = messageCampaign(); + + expect(app(CampaignMessageService::class)->postFromYandex($campaign, null, ' '))->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); +``` + +- [ ] **Шаг 2: убедиться, что тесты падают** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageService` + +Ожидаемо: FAIL — класса `CampaignMessageService` нет. + +- [ ] **Шаг 3: сервис** + +Создать `app/app/Services/Advertising/CampaignMessageService.php`: + +```php +getConnectionName(); + + $last = AdCampaignMessage::on($connection) + ->where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_YANDEX) + ->where(fn ($q) => $bannerId === null ? $q->whereNull('banner_id') : $q->where('banner_id', $bannerId)) + ->orderByDesc('id') + ->first(); + + if ($last !== null && $last->body === $body) { + return null; + } + + $message = new AdCampaignMessage([ + 'tenant_id' => (int) $campaign->tenant_id, + 'campaign_id' => (int) $campaign->id, + 'banner_id' => $bannerId, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, + 'body' => $body, + ]); + $message->setConnection($connection); + $message->save(); + + return $message; + } +} +``` + +- [ ] **Шаг 4: убедиться, что тесты зелёные** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageService` + +Ожидаемо: PASS, 6 тестов. + +- [ ] **Шаг 5: проверить защиту вырезанием** + +Скопировать файл в scratchpad, затем убрать проверку дубля (`if ($last !== null && $last->body === $body)`), +запустить тесты — тест «тот же текст второй раз» обязан покраснеть. Вернуть файл из scratchpad. + +- [ ] **Шаг 6: коммит** (по эскейпу владельца) + +--- + +## Задача 3: джоб модерации кладёт пояснение в ленту + +**Файлы:** + +- Изменить: `app/app/Jobs/SyncCampaignModerationJob.php` +- Тест: `app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php` (дописать) + +- [ ] **Шаг 1: тест, который краснеет** + +Дописать в `app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php` (использовать +существующий в файле помощник `configureYandexForModeration()` и его же способ завести +кампанию с баннером — посмотреть соседние тесты): + +```php +use App\Models\AdCampaignMessage; + +it('причина отказа попадает в ленту сообщений кампании', function () { + configureYandexForModeration(); + + [$tenant, $campaign, $banner] = moderationCampaignWithBanner(7001); + + Http::fake([ + '*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 7001, + 'State' => 'ON', + 'Status' => 'REJECTED', + 'StatusClarification' => str_repeat('Претензия модератора. ', 30), + ]]]]), + ]); + + (new SyncCampaignModerationJob)->handle(); + + $message = AdCampaignMessage::where('campaign_id', $campaign->id)->first(); + + expect($message)->not->toBeNull() + ->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX) + ->and($message->banner_id)->toBe($banner->id) + // 🔑 в ленте текст ЦЕЛИКОМ, а в ярлыке — обрезанный до 255 + ->and(mb_strlen($message->body))->toBeGreaterThan(255) + ->and(mb_strlen((string) $banner->refresh()->moderation_reason))->toBe(255); +}); + +it('беда с лентой не срывает обход остальных кампаний', function () { + configureYandexForModeration(); + + [, $campaign] = moderationCampaignWithBanner(7002); + + // Сервис сообщений падает — но статус модерации записаться обязан: сорванный обход + // это чужая реклама, про которую никто не узнал, и не вернувшиеся деньги. + $this->app->bind(CampaignMessageService::class, fn () => throw new RuntimeException('лента легла')); + + Http::fake([ + '*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 7002, 'State' => 'ON', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято', + ]]]]), + ]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->banners()->first()->moderation_status)->toBe('ACCEPTED'); +}); +``` + +🪤 Если помощника `moderationCampaignWithBanner()` в файле нет — написать его рядом +с `configureYandexForModeration()` по образцу существующих тестов файла, чтобы он создавал +тенант, кампанию в статусе `pending_moderation` с `yandex_campaign_id` и один баннер +с переданным `yandex_ad_id`. + +- [ ] **Шаг 2: убедиться, что тесты падают** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=SyncCampaignModeration` + +Ожидаемо: FAIL — сообщений в ленте нет. + +- [ ] **Шаг 3: правка джоба** + +В `app/app/Jobs/SyncCampaignModerationJob.php`, внутри цикла по баннерам, сразу после +`$banner->update([...])` добавить: + +```php + // Пояснение модератора кладём в ленту кампании ЦЕЛИКОМ: в колонке + // баннера оно обрезано до 255 знаков ради ярлыка, а клиенту нужен + // весь текст — именно по нему он поймёт, что переделывать. + // + // Отдельный try: лента — вещь второстепенная, а статус модерации нет. + // Беда с лентой не должна стоить клиенту незаписанного вердикта и + // невозвращённых денег. + if (is_string($reason) && trim($reason) !== '') { + try { + app(CampaignMessageService::class)->postFromYandex( + $campaign, (int) $banner->id, $reason, + ); + } catch (Throwable $e) { + Log::warning('Не смогли положить пояснение Яндекса в ленту: '.$e->getMessage(), [ + 'campaign' => $campaign->id, 'banner' => $banner->id, + ]); + } + } +``` + +Добавить в шапку файла `use App\Services\Advertising\CampaignMessageService;`. + +- [ ] **Шаг 4: убедиться, что тесты зелёные** + +Запустить полный набор: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + +- [ ] **Шаг 5: проверить вырезанием** + +Убрать внутренний `try/catch` вокруг вызова сервиса — тест «беда с лентой не срывает обход» +обязан покраснеть. Вернуть из scratchpad. + +- [ ] **Шаг 6: коммит** (по эскейпу владельца) + +--- + +## Задача 4: письмо клиенту и колокольчик + +**Файлы:** + +- Создать: `app/app/Mail/AdModerationMessageMail.php` +- Создать: `app/resources/views/mail/ad-moderation-message.blade.php` +- Изменить: `app/app/Services/Advertising/CampaignMessageService.php` +- Тест: `app/tests/Feature/Advertising/CampaignMessageNotifyTest.php` + +- [ ] **Шаг 1: тест, который краснеет** + +Создать `app/tests/Feature/Advertising/CampaignMessageNotifyTest.php`: + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не подошло'); + + Mail::assertQueued(AdModerationMessageMail::class, fn ($mail) => $mail->hasTo($user->email)); + + expect(InAppNotification::where('tenant_id', $tenant->id)->where('user_id', $user->id)->count())->toBe(1); +}); + +/** + * Служебная отметка «документ отправлен в Яндекс» — расписка, а не новость. + * Дёргать ею клиента незачем. + */ +it('на служебную отметку портала письмо не уходит', function () { + Mail::fake(); + + $tenant = Tenant::factory()->create(); + User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + app(CampaignMessageService::class)->postSystem($campaign, 'Документ отправлен в Яндекс'); + + Mail::assertNothingQueued(); + expect(InAppNotification::where('tenant_id', $tenant->id)->count())->toBe(0); +}); + +/** + * Почта — вещь ненадёжная. Если письмо не ушло, сообщение всё равно обязано остаться + * в ленте: клиент увидит его, когда зайдёт. Иначе один сбой почты стирает саму новость. + */ +it('упавшая почта не мешает сообщению лечь в ленту', function () { + $tenant = Tenant::factory()->create(); + User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + Mail::shouldReceive('to')->andThrow(new RuntimeException('почта легла')); + + $message = app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не подошло'); + + expect($message)->not->toBeNull() + ->and($message->exists)->toBeTrue(); +}); +``` + +- [ ] **Шаг 2: убедиться, что тесты падают** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageNotify` + +Ожидаемо: FAIL — нет класса письма и метода `postSystem`. + +- [ ] **Шаг 3: письмо** + +Создать `app/app/Mail/AdModerationMessageMail.php`: + +```php +queue(new ...). + */ +final class AdModerationMessageMail extends Mailable +{ + use Queueable; + use SerializesModels; + + public function __construct( + public readonly string $campaignName, + public readonly int $campaignId, + public readonly string $body, + ) {} + + public function envelope(): Envelope + { + return new Envelope(subject: 'Ответ Яндекса по рекламной кампании «'.$this->campaignName.'»'); + } + + public function content(): Content + { + return new Content( + view: 'mail.ad-moderation-message', + with: [ + 'campaignName' => $this->campaignName, + 'campaignId' => $this->campaignId, + 'body' => $this->body, + ], + ); + } +} +``` + +Создать `app/resources/views/mail/ad-moderation-message.blade.php`: + +```blade +

По вашей рекламной кампании «{{ $campaignName }}» пришёл ответ Яндекса:

+ +
+ {{ $body }} +
+ +

Открыть кампанию в личном кабинете и ответить: раздел «Реклама» → кампания «{{ $campaignName }}».

+ +

Это письмо отправлено автоматически, отвечать на него не нужно.

+``` + +- [ ] **Шаг 4: уведомления в сервисе** + +В `app/app/Services/Advertising/CampaignMessageService.php` добавить в конструктор +зависимость и метод уведомления, а в `postFromYandex()` — вызов после `$message->save()`: + +```php +use App\Mail\AdModerationMessageMail; +use App\Models\User; +use App\Services\NotificationService; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Throwable; + + public function __construct(private readonly NotificationService $notifications) {} + + /** Служебная отметка портала: расписка, а не новость — клиента ею не дёргаем. */ + public function postSystem(AdCampaign $campaign, string $body): ?AdCampaignMessage + { + $body = trim($body); + if ($body === '') { + return null; + } + + $message = new AdCampaignMessage([ + 'tenant_id' => (int) $campaign->tenant_id, + 'campaign_id' => (int) $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_SYSTEM, + 'body' => $body, + ]); + $message->setConnection($campaign->getConnectionName()); + $message->save(); + + return $message; + } + + /** + * Письмо и колокольчик всем живым пользователям тенанта. Внутри всё под Throwable: + * упавшая почта не должна стирать саму новость — сообщение уже в ленте, клиент + * увидит его, когда зайдёт. + */ + private function notify(AdCampaign $campaign, string $body): void + { + try { + $users = User::query() + ->where('tenant_id', $campaign->tenant_id) + ->where('is_active', true) + ->whereNull('deleted_at') + ->get(); + + foreach ($users as $user) { + $this->notifications->notifyInApp( + $user, + 'ad_moderation', + 'Ответ Яндекса по рекламе', + mb_substr($body, 0, 500), + ['campaign_id' => (int) $campaign->id], + ); + + if (is_string($user->email) && $user->email !== '') { + Mail::to($user->email)->queue(new AdModerationMessageMail( + (string) $campaign->name, (int) $campaign->id, $body, + )); + } + } + } catch (Throwable $e) { + Log::warning('Не смогли уведомить клиента об ответе Яндекса: '.$e->getMessage(), [ + 'campaign' => $campaign->id, + ]); + } + } +``` + +В `postFromYandex()` после `$message->save()` дописать `$this->notify($campaign, $body);`. + +- [ ] **Шаг 5: убедиться, что тесты зелёные** + +Запустить полный набор портала. + +- [ ] **Шаг 6: проверить вырезанием** + +Убрать `try/catch` вокруг тела `notify()` — тест «упавшая почта не мешает» обязан покраснеть. +Вернуть из scratchpad. + +- [ ] **Шаг 7: коммит** (по эскейпу владельца) + +--- + +## 🛑 Конец захода 1 — точка компакта + +- [ ] Полный прогон обоих наборов, по одному процессу +- [ ] `grep -rn -- "->release(" app/app` — денежных мест по-прежнему четыре +- [ ] Обновить `docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md`: что сделано, + что проверено вырезанием, зелёные числа +- [ ] Записать состояние в `docs/superpowers/2026-07-28-STATE-okno-peredachi.md`: + последний коммит, какие задачи закрыты, что дальше, свежие грабли +- [ ] Коммит по эскейпу владельца +- [ ] Сказать владельцу, что можно делать компакт + +--- + +# ЗАХОД 2 — клиент читает и отвечает + +## Задача 5: ручка списка сообщений + +**Файлы:** + +- Создать: `app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php` +- Изменить: `app/routes/web.php` +- Тест: `app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php` + +- [ ] **Шаг 1: тесты, которые краснеют** + +Создать `app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php`: + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + return [$tenant, $user, $campaign]; +} + +it('клиент видит ленту своей кампании по порядку', function () { + [$tenant, $user, $campaign] = messageEndpointCampaign(); + + AdCampaignMessage::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, 'body' => 'Первое', + ]); + AdCampaignMessage::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, 'body' => 'Второе', + ]); + + $res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/messages"); + + $res->assertOk(); + expect($res->json('messages.0.body'))->toBe('Первое') + ->and($res->json('messages.1.body'))->toBe('Второе'); +}); + +/** Чужая переписка — чужие бумаги и чужие претензии модератора. */ +it('чужую ленту клиент не видит', function () { + [$tenantA, , $campaignA] = messageEndpointCampaign(); + AdCampaignMessage::create([ + 'tenant_id' => $tenantA->id, 'campaign_id' => $campaignA->id, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, 'body' => 'Чужое', + ]); + + [, $userB] = messageEndpointCampaign(); + + $this->actingAs($userB) + ->getJson("/api/advertising/campaigns/{$campaignA->id}/messages") + ->assertStatus(404); +}); + +it('без входа лента не отдаётся', function () { + [, , $campaign] = messageEndpointCampaign(); + + $this->getJson("/api/advertising/campaigns/{$campaign->id}/messages")->assertStatus(401); +}); +``` + +- [ ] **Шаг 2: убедиться, что тесты падают** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageEndpoints` + +Ожидаемо: FAIL 404 — маршрута нет. + +🪤 На незнакомый адрес в этом проекте отвечает страница сайта с кодом 200 — голого +`assertOk()` для доказательства мало, поэтому в тестах проверяется содержимое. + +- [ ] **Шаг 3: контроллер** + +Создать `app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php`: + +```php +user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $messages = AdCampaignMessage::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->orderBy('id') + ->get() + ->map(fn (AdCampaignMessage $m) => [ + 'id' => (int) $m->id, + 'author' => $m->author, + 'banner_id' => $m->banner_id, + 'body' => $m->body, + 'file_name' => $m->file_name, + 'file_size' => $m->file_size, + 'created_at' => $m->created_at?->toIso8601String(), + ]); + + return response()->json(['messages' => $messages]); + } +} +``` + +- [ ] **Шаг 4: маршрут** + +В `app/routes/web.php`, в группу `Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')`, +рядом с остальными маршрутами кампаний добавить: + +```php + Route::get('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@index')->whereNumber('id'); +``` + +- [ ] **Шаг 5: убедиться, что тесты зелёные** + +Запустить полный набор портала. + +- [ ] **Шаг 6: коммит** (по эскейпу владельца) + +--- + +## Задача 6: ответ клиента с файлом + +**Файлы:** + +- Изменить: `app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php` +- Изменить: `app/routes/web.php` +- Тест: `app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php` (дописать) + +- [ ] **Шаг 1: тесты, которые краснеют** + +Дописать в `app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php`: + +```php +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; + +it('клиент отправляет ответ с документом', function () { + Storage::fake('local'); + [$tenant, $user, $campaign] = messageEndpointCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'body' => 'Прикладываю свидетельство', + 'file' => UploadedFile::fake()->create('svidetelstvo.pdf', 200, 'application/pdf'), + ]); + + $res->assertStatus(201); + + $message = AdCampaignMessage::where('campaign_id', $campaign->id)->first(); + expect($message->author)->toBe(AdCampaignMessage::AUTHOR_CLIENT) + ->and($message->file_name)->toBe('svidetelstvo.pdf') + ->and($message->file_path)->not->toBeNull(); + + Storage::disk('local')->assertExists($message->file_path); +}); + +it('ответ без текста и без файла не принимается', function () { + Storage::fake('local'); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user) + ->postJson("/api/advertising/campaigns/{$campaign->id}/messages", []) + ->assertStatus(422); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** + * Приложение исполняемого файла — не «неудобство», а дыра: файл ляжет на диск боевого + * сервера, и дальше вопрос только в том, кто его оттуда позовёт. + */ +it('файл не того типа отклоняется и на диск не ложится', function () { + Storage::fake('local'); + [, $user, $campaign] = messageEndpointCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'body' => 'вот', + 'file' => UploadedFile::fake()->create('opasno.exe', 10, 'application/octet-stream'), + ]); + + $res->assertStatus(422); + $res->assertJsonValidationErrors('file'); + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +it('файл тяжелее предела отклоняется', function () { + Storage::fake('local'); + [, $user, $campaign] = messageEndpointCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'file' => UploadedFile::fake()->create('big.pdf', 10241, 'application/pdf'), + ]); + + $res->assertStatus(422); + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +it('в чужую кампанию написать нельзя', function () { + Storage::fake('local'); + [, , $campaignA] = messageEndpointCampaign(); + [, $userB] = messageEndpointCampaign(); + + $this->actingAs($userB) + ->postJson("/api/advertising/campaigns/{$campaignA->id}/messages", ['body' => 'привет']) + ->assertStatus(404); +}); + +it('свой файл клиент скачивает, чужой — нет', function () { + Storage::fake('local'); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'file' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'), + ])->assertStatus(201); + + $message = AdCampaignMessage::where('campaign_id', $campaign->id)->first(); + + $this->actingAs($user) + ->get("/api/advertising/campaigns/{$campaign->id}/messages/{$message->id}/file") + ->assertOk(); + + [, $userB] = messageEndpointCampaign(); + $this->actingAs($userB) + ->get("/api/advertising/campaigns/{$campaign->id}/messages/{$message->id}/file") + ->assertStatus(404); +}); +``` + +- [ ] **Шаг 2: убедиться, что тесты падают** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=CampaignMessageEndpoints` + +- [ ] **Шаг 3: методы контроллера** + +Дописать в `app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php`: + +```php +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\StreamedResponse; + + /** + * Ответ клиента. Пустое сообщение без файла принимать бессмысленно — окно передачи + * должно что-то передавать. + * + * Что принимаем: pdf, jpg, png до 10 МБ. Проверяем и расширение, и настоящий тип + * файла — переименованный exe правилом `mimes` не пройдёт. + */ + public function store(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $data = $request->validate([ + 'body' => ['nullable', 'string', 'max:4000'], + 'file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'], + ]); + + $body = trim((string) ($data['body'] ?? '')); + $file = $request->file('file'); + + if ($body === '' && ! $file instanceof UploadedFile) { + return response()->json([ + 'message' => 'Напишите сообщение или приложите документ.', + 'errors' => ['body' => ['Напишите сообщение или приложите документ.']], + ], 422); + } + + $attributes = [ + 'tenant_id' => $tenantId, + 'campaign_id' => (int) $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => $body === '' ? 'Приложен документ' : $body, + ]; + + if ($file instanceof UploadedFile) { + // Приватный диск: наружу файл уходит только через ручку ниже, с проверкой тенанта. + $path = $file->store("ad-messages/{$tenantId}/{$campaign->id}", 'local'); + + $attributes += [ + 'file_path' => $path, + 'file_name' => mb_substr($file->getClientOriginalName(), 0, 255), + 'file_size' => $file->getSize(), + 'file_mime' => $file->getMimeType(), + ]; + } + + $message = AdCampaignMessage::create($attributes); + + return response()->json(['id' => (int) $message->id], 201); + } + + /** Файл отдаём только своему тенанту и только через портал — диск закрытый. */ + public function file(Request $request, int $id, int $messageId): StreamedResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $message = AdCampaignMessage::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where('id', $messageId) + ->whereNotNull('file_path') + ->firstOrFail(); + + return Storage::disk('local')->download((string) $message->file_path, (string) $message->file_name); + } +``` + +- [ ] **Шаг 4: маршруты** + +В `app/routes/web.php` рядом с маршрутом списка добавить: + +```php + Route::post('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@store')->whereNumber('id'); + Route::get('/campaigns/{id}/messages/{messageId}/file', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@file')->whereNumber(['id', 'messageId']); +``` + +- [ ] **Шаг 5: убедиться, что тесты зелёные** + +Запустить полный набор портала. + +- [ ] **Шаг 6: проверить защиты вырезанием** + +По очереди убрать: правило `mimes`, правило `max:10240`, условие «пусто и без файла», +фильтр `where('tenant_id', $tenantId)` в `file()`. Каждый раз убеждаться, что краснеет +соответствующий тест. Возвращать файл из scratchpad. + +- [ ] **Шаг 7: коммит** (по эскейпу владельца) + +--- + +## Задача 7: экран ленты + +**Файлы:** + +- Создать: `app/resources/js/components/advertising/CampaignMessages.vue` +- Изменить: `app/resources/js/api/advertising.ts` +- Изменить: `app/resources/js/components/advertising/CampaignReportDialog.vue` + +- [ ] **Шаг 1: типы и вызовы в `advertising.ts`** + +Дописать: + +```ts +/** Сообщение ленты кампании — окно передачи между Яндексом и клиентом. */ +export interface CampaignMessage { + id: number; + author: 'yandex' | 'client' | 'system'; + banner_id: number | null; + body: string; + file_name: string | null; + file_size: number | null; + created_at: string | null; +} + +/** GET /api/advertising/campaigns/{id}/messages — лента сообщений кампании. */ +export async function fetchCampaignMessages(id: number): Promise { + const { data } = await apiClient.get<{ messages: CampaignMessage[] }>(`/api/advertising/campaigns/${id}/messages`); + return data.messages; +} + +/** POST /api/advertising/campaigns/{id}/messages — ответ клиента, можно с файлом. */ +export async function sendCampaignMessage(id: number, body: string, file: File | null): Promise { + const form = new FormData(); + if (body !== '') form.append('body', body); + if (file !== null) form.append('file', file); + await apiClient.post(`/api/advertising/campaigns/${id}/messages`, form); +} +``` + +- [ ] **Шаг 2: компонент ленты** + +Создать `app/resources/js/components/advertising/CampaignMessages.vue`: + +```vue + + + +``` + +- [ ] **Шаг 3: встроить в диалог кампании** + +В `app/resources/js/components/advertising/CampaignReportDialog.vue` заменить блок списка +`detail.ads` (он всегда пустой — таблица `ad_campaign_ads` от старой модели не заполняется) +на компонент ленты: + +```vue + + +``` + +и добавить импорт `import CampaignMessages from './CampaignMessages.vue';`. + +- [ ] **Шаг 4: проверка сборки** + +Запустить: `cd app && npm run type-check` + +Ожидаемо: тех же 6 чужих ошибок, что были до ветки, и ни одной новой. Чужие не чинить. + +- [ ] **Шаг 5: коммит** (по эскейпу владельца) + +--- + +## Задача 8: причина отказа видна в списке кампаний + +**Файлы:** + +- Изменить: `app/resources/js/components/advertising/CampaignList.vue` +- Изменить: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` (метод `index`) +- Тест: `app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php` (дописать) + +- [ ] **Шаг 1: тест, который краснеет** + +Дописать в `app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php`: + +```php +it('в списке кампаний у отклонённой видна причина, а наценки нет', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + 'status' => AdCampaign::STATUS_REJECTED, 'moderation_reason' => 'Изображение не подошло', + ]); + + $res = $this->actingAs($user)->getJson('/api/advertising/campaigns'); + + $res->assertOk(); + // Ответ index() — обёртка {"data": [...]}, проверено по коду 28.07.2026 + // (AdvertisingCampaignController.php:53). + $row = collect($res->json('data'))->firstWhere('id', $campaign->id); + + expect($row['moderation_reason'])->toBe('Изображение не подошло'); + expect($res->getContent())->not->toContain('yandex_cost_rub') + ->and($res->getContent())->not->toContain('ad_margin_percent'); +}); +``` + +- [ ] **Шаг 2: убедиться, что тест падает** + +Запустить: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=AdvertisingCampaignEndpoint` + +- [ ] **Шаг 3: отдать поле в списке** + +В `AdvertisingCampaignController::index()` (строка 51) дописать `moderation_reason` +в явный список колонок: + +```php + ->get(['id', 'name', 'status', 'audience_days', 'frequency', 'estimated_impressions', 'budget_rub', 'launched_at', 'moderation_reason']); +``` + +**`yandex_cost_rub` и `ad_margin_percent` не добавлять никогда** — наценка в клиентский +JSON не попадает ни при каких обстоятельствах; `$hidden` модели её прячет, и это должно +остаться так. + +- [ ] **Шаг 4: подпись под ярлыком** + +В `app/resources/js/components/advertising/CampaignList.vue` под чипом статуса показать +первую строку причины, если статус `rejected`: + +```vue +
+ {{ campaign.moderation_reason }} +
+``` + +Добавить `moderation_reason?: string | null;` в тип кампании в `advertising.ts`. + +- [ ] **Шаг 5: зелёный прогон и `npm run type-check`** + +- [ ] **Шаг 6: коммит** (по эскейпу владельца) + +--- + +## 🛑 Конец захода 2 — точка компакта. Кусок 1 закрыт + +- [ ] Полный прогон обоих наборов +- [ ] Пройти по приёмочному листу замысла §8, раздел «Кусок 1», отметить каждую строку +- [ ] Обновить PROGRESS и STATE-файл +- [ ] Коммит по эскейпу владельца +- [ ] Показать владельцу, что получилось, и сказать, что можно делать компакт + +--- + +# ЗАХОД 3 — «Исправить» начинает работать + +## Задача 9: удаление отклонённых объявлений в Яндексе + +**Файлы:** + +- Изменить: `app/app/Services/Advertising/YandexDirectClient.php` +- Тест: `app/tests/Feature/Advertising/YandexDirectMediaClientTest.php` (дописать) + +- [ ] **Шаг 1: тест, который краснеет** + +```php +it('удаление объявлений уходит одним запросом со всеми номерами', function () { + Http::fake(['*' => Http::response(['result' => ['DeleteResults' => [['Id' => 11], ['Id' => 12]]]])]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com/json/', 'token'); + $client->deleteAds([11, 12]); + + Http::assertSent(function ($request) { + $body = $request->data(); + + return str_contains($request->url(), 'v5/ads') + && $body['method'] === 'delete' + && $body['params']['SelectionCriteria']['Ids'] === [11, 12]; + }); +}); + +it('пустой список объявлений в Яндекс не уходит', function () { + Http::fake(); + + (new YandexDirectClient('https://api-sandbox.direct.yandex.com/json/', 'token'))->deleteAds([]); + + Http::assertNothingSent(); +}); +``` + +- [ ] **Шаг 2: убедиться, что тесты падают** — метода `deleteAds` нет. + +- [ ] **Шаг 3: метод** + +```php + /** + * ads.delete — убрать объявления из кабинета. Зовём только для отклонённых: + * они всё равно не показываются и денег не стоят, но копить их в кабинете незачем. + * Пустой список наружу не отправляем: Яндекс ответил бы ошибкой на пустое дело. + * + * @param array $adIds + */ + public function deleteAds(array $adIds): void + { + $adIds = array_values(array_filter($adIds, fn ($id) => (int) $id > 0)); + if ($adIds === []) { + return; + } + + $this->call('v5/ads', 'delete', ['SelectionCriteria' => ['Ids' => $adIds]]); + } +``` + +- [ ] **Шаг 4: зелёный прогон** + +- [ ] **Шаг 5: коммит** (по эскейпу владельца) + +--- + +## Задача 10: сервис оживления + +**Файлы:** + +- Создать: `app/app/Services/Advertising/CampaignReviveService.php` +- Тест: `app/tests/Feature/Advertising/CampaignReviveServiceTest.php` + +- [ ] **Шаг 1: тесты, которые краснеют** + +Создать `app/tests/Feature/Advertising/CampaignReviveServiceTest.php`: + +```php +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + 'status' => AdCampaign::STATUS_REJECTED, + 'yandex_campaign_id' => 555, 'yandex_ad_group_id' => 666, 'yandex_segment_id' => 777, + 'moderation_reason' => 'Изображение не подошло', + ]); + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, + 'path' => 'ad-banners/a/300x250.jpg', + 'yandex_creative_id' => 100, 'yandex_ad_id' => 200, + 'moderation_status' => AdCampaignBanner::MOD_REJECTED, + ]); + + return [$tenant, $campaign, $banner]; +} + +it('оживление возвращает кампанию в черновик и чистит номера отклонённых объявлений', function () { + config(['services.yandex_direct.enabled' => true]); + Http::fake(['*' => Http::response(['result' => ['DeleteResults' => [['Id' => 200]]]])]); + + [, $campaign, $banner] = rejectedCampaign(); + + app(CampaignReviveService::class)->revive($campaign); + + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT) + ->and($banner->refresh()->yandex_creative_id)->toBeNull() + ->and($banner->refresh()->yandex_ad_id)->toBeNull(); +}); + +/** + * Номера кампании, группы и сегмента — то, на чём держится возобновляемый запуск. + * Стереть их — значит завести в Яндексе второй комплект всего и заплатить дважды. + */ +it('номера кампании, группы и сегмента при оживлении уцелевают', function () { + config(['services.yandex_direct.enabled' => true]); + Http::fake(['*' => Http::response(['result' => ['DeleteResults' => [['Id' => 200]]]])]); + + [, $campaign] = rejectedCampaign(); + + app(CampaignReviveService::class)->revive($campaign); + + $fresh = $campaign->refresh(); + expect((int) $fresh->yandex_campaign_id)->toBe(555) + ->and((int) $fresh->yandex_ad_group_id)->toBe(666) + ->and((int) $fresh->yandex_segment_id)->toBe(777); +}); + +it('оживить можно только отклонённую кампанию', function () { + [, $campaign] = rejectedCampaign(); + $campaign->update(['status' => AdCampaign::STATUS_RUNNING]); + + expect(fn () => app(CampaignReviveService::class)->revive($campaign)) + ->toThrow(RuntimeException::class); +}); + +/** Яндекс не удалил объявление — это не повод не дать клиенту исправиться. */ +it('неудача удаления в Яндексе не мешает оживлению', function () { + config(['services.yandex_direct.enabled' => true]); + Http::fake(['*' => Http::response(['error' => ['error_string' => 'нет такого']], 200)]); + + [, $campaign, $banner] = rejectedCampaign(); + + app(CampaignReviveService::class)->revive($campaign); + + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT) + ->and($banner->refresh()->yandex_ad_id)->toBeNull(); +}); + +it('при выключенном рубильнике в Яндекс не ходим вовсе', function () { + config(['services.yandex_direct.enabled' => false]); + Http::fake(); + + [, $campaign] = rejectedCampaign(); + + app(CampaignReviveService::class)->revive($campaign); + + Http::assertNothingSent(); + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); +``` + +- [ ] **Шаг 2: убедиться, что тесты падают** + +- [ ] **Шаг 3: сервис** + +Создать `app/app/Services/Advertising/CampaignReviveService.php`: + +```php +status !== AdCampaign::STATUS_REJECTED) { + throw new RuntimeException('Оживить можно только отклонённую кампанию.'); + } + + $rejected = AdCampaignBanner::where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->where('moderation_status', AdCampaignBanner::MOD_REJECTED) + ->get(); + + // Убрать мёртвые объявления из кабинета. Не вышло — не беда: отклонённое + // объявление не показывается и денег не стоит. Клиента это блокировать не должно. + if (config('services.yandex_direct.enabled')) { + try { + $client = new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + $client->deleteAds($rejected->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all()); + } catch (Throwable $e) { + Log::warning('Не смогли убрать отклонённые объявления из кабинета: '.$e->getMessage(), [ + 'campaign' => $campaign->id, + ]); + } + } + + DB::transaction(function () use ($campaign, $rejected): void { + foreach ($rejected as $banner) { + // Картинке нужен новый креатив и новое объявление: заменить креатив + // у существующего объявления Яндекс не даёт. + $banner->update([ + 'yandex_creative_id' => null, + 'yandex_ad_id' => null, + 'moderation_status' => AdCampaignBanner::MOD_DRAFT, + 'moderation_reason' => null, + ]); + } + + // Номера кампании, группы и сегмента НЕ трогаем — на них держится + // возобновляемый запуск. Стереть их значит завести в Яндексе второй + // комплект всего и заплатить дважды. + $campaign->update([ + 'status' => AdCampaign::STATUS_DRAFT, + 'moderation_reason' => null, + ]); + }); + } +} +``` + +- [ ] **Шаг 4: зелёный прогон** + +- [ ] **Шаг 5: проверить вырезанием** + +Убрать проверку статуса — тест «оживить можно только отклонённую» краснеет. +Добавить обнуление `yandex_campaign_id` — тест «номера уцелевают» краснеет. +Возвращать файл из scratchpad. + +- [ ] **Шаг 6: коммит** (по эскейпу владельца) + +--- + +## Задача 11: ручка «Исправить» и узкое исключение в замке + +**Файлы:** + +- Изменить: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` +- Изменить: `app/routes/web.php` +- Тест: `app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php` (дописать) +- Тест: `app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php` (дописать) + +- [ ] **Шаг 1: тесты, которые краснеют** + +```php +/** Кампания заданного статуса, уже заведённая в Яндексе, со своим пользователем. */ +function campaignInYandex(string $status): array +{ + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + 'status' => $status, + 'yandex_campaign_id' => 555, 'yandex_ad_group_id' => 666, 'yandex_segment_id' => 777, + ]); + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, + 'path' => 'ad-banners/a/300x250.jpg', + 'yandex_creative_id' => 100, 'yandex_ad_id' => 200, + 'moderation_status' => AdCampaignBanner::MOD_REJECTED, + ]); + + return [$tenant, $user, $campaign]; +} + +it('клиент оживляет свою отклонённую кампанию', function () { + config(['services.yandex_direct.enabled' => false]); + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive"); + + $res->assertOk(); + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +it('оживить работающую кампанию нельзя', function () { + config(['services.yandex_direct.enabled' => false]); + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING); + + $this->actingAs($user) + ->postJson("/api/advertising/campaigns/{$campaign->id}/revive") + ->assertStatus(409); + + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +/** + * Двойной щелчок по «Исправить». Второй заход видит кампанию уже черновиком — и обязан + * получить отказ, а не пройти второй раз по чистке номеров: он стёр бы номера объявлений, + * которые к тому моменту мог создать новый запуск. + */ +it('второе нажатие «Исправить» получает отказ', function () { + config(['services.yandex_direct.enabled' => false]); + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertOk(); + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/revive")->assertStatus(409); +}); + +it('чужую кампанию оживить нельзя', function () { + config(['services.yandex_direct.enabled' => false]); + [, , $campaignA] = campaignInYandex(AdCampaign::STATUS_REJECTED); + [, $userB] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $this->actingAs($userB) + ->postJson("/api/advertising/campaigns/{$campaignA->id}/revive") + ->assertStatus(404); +}); + +it('у отклонённой кампании правка настроек разрешена', function () { + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_REJECTED); + + $this->actingAs($user) + ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) + ->assertOk(); +}); + +/** 🔑 Исключение не должно протечь на работающую рекламу — она крутится за деньги клиента. */ +it('у работающей кампании правка настроек по-прежнему заперта', function () { + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_RUNNING); + + $this->actingAs($user) + ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) + ->assertStatus(409); +}); +``` + +В `CampaignBannerEndpointsTest.php` — та же пара про перезаливку картинки: +у отклонённой разрешена, у работающей заперта. + +- [ ] **Шаг 2: убедиться, что тесты падают** + +- [ ] **Шаг 3: узкое исключение в замке** + +В `AdvertisingCampaignController::update()` заменить строку признака: + +```php + // Замок «кампания уже в Яндексе» получает ОДНО узкое исключение: отклонённую + // кампанию клиент обязан иметь возможность починить. У неё показов нет и денег + // на ней нет — расходиться с Яндексом нечему. + // + // 🪤 Исключение написано ПО СТАТУСУ, а не по «есть ли номер кампании»: иначе оно + // откроет правку и работающей рекламе. + $inYandex = ($campaign->yandex_campaign_id !== null || $campaign->yandex_segment_id !== null) + && $campaign->status !== AdCampaign::STATUS_REJECTED; +``` + +Ту же правку сделать в `bannersLocked()`. + +- [ ] **Шаг 4: ручка** + +```php + public function revive(Request $request, int $id, CampaignReviveService $service): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + try { + $service->revive($campaign); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 409); + } + + return response()->json(['status' => $campaign->refresh()->status]); + } +``` + +Маршрут в `app/routes/web.php`: + +```php + Route::post('/campaigns/{id}/revive', 'App\Http\Controllers\Api\AdvertisingCampaignController@revive')->whereNumber('id'); +``` + +- [ ] **Шаг 5: зелёный прогон + счёт мест разморозки** + +```bash +cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising +grep -rn -- "->release(" app/app +``` + +Денежных мест обязано остаться **четыре**. + +- [ ] **Шаг 6: проверить вырезанием** + +Убрать `&& $campaign->status !== AdCampaign::STATUS_REJECTED` — краснеют тесты про правку +отклонённой. Заменить условие на «всегда разрешено» — краснеют тесты про работающую. + +- [ ] **Шаг 7: коммит** (по эскейпу владельца) + +--- + +## Задача 12: кнопка «Исправить» на экране + +**Файлы:** + +- Изменить: `app/resources/js/api/advertising.ts` +- Изменить: `app/resources/js/components/advertising/CampaignList.vue` + +- [ ] **Шаг 1: вызов в `advertising.ts`** + +```ts +/** POST /api/advertising/campaigns/{id}/revive — вернуть отклонённую кампанию в черновик. */ +export async function reviveCampaign(id: number): Promise { + await apiClient.post(`/api/advertising/campaigns/${id}/revive`); +} +``` + +- [ ] **Шаг 2: кнопка** + +В `CampaignList.vue` кнопка «Исправить» у отклонённой кампании сначала зовёт +`reviveCampaign(campaign.id)`, потом открывает мастер правки — как обычное «Изменить» +для черновика. После успеха список перезагружается. + +- [ ] **Шаг 3: `npm run type-check`** — новых ошибок нет + +- [ ] **Шаг 4: живая проверка в браузере** + +Пройти путь глазами: отклонённая кампания → «Исправить» → мастер открылся → картинка +меняется → «Запустить» доступна. 🪤 Сервер держит старый код в памяти — при непонятном +поведении перезапустить и проверить ещё раз. + +- [ ] **Шаг 5: коммит** (по эскейпу владельца) + +--- + +## 🛑 Конец захода 3 — точка компакта. Кусок 2 закрыт + +- [ ] Полный прогон обоих наборов +- [ ] Приёмочный лист замысла §8, раздел «Кусок 2» — отметить каждую строку +- [ ] `grep -rn -- "->release(" app/app` — денежных мест четыре +- [ ] Обновить PROGRESS и STATE-файл +- [ ] Коммит по эскейпу владельца + +--- + +# ЗАХОД 4 — робот идёт в кабинет + +## Задача 13: 🔴 снять живую разметку кабинета — ДО кода + +**Это не программирование. Это наблюдение, и без него заход 4 не начинается.** + +- [ ] **Шаг 1: договориться с владельцем о заходе в кабинет** + +Вход в живой рекламный кабинет Яндекса — **только вместе с владельцем**. + +- [ ] **Шаг 2: вызвать отказ нарочно** + +Залить заведомо непроходное объявление и дождаться вердикта модерации. Ждать живого +клиентского отказа не нужно. + +- [ ] **Шаг 3: записать увиденное** + +Дописать в `bots/yandex-creatives/docs/cabinet-flow.md` раздел «Экран отклонённого +объявления»: как выглядит причина, где она лежит в разметке, как выглядит требование +документов, куда прикладывается файл, какой кнопкой отправляется на повторную модерацию. +Приложить снимки экрана. + +- [ ] **Шаг 4: коммит наблюдений** (по эскейпу владельца) + +🔴 Пока этот раздел не написан **по натуре**, задачи 14–16 не начинаются. Писать робота +по воображаемым экранам — верный способ получить зелёные тесты и мёртвую программу. + +--- + +## Задача 14: вид задания у робота + +**Файлы:** + +- Создать: `app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php` +- Изменить: `app/app/Models/AdCreativeJob.php` +- Тест: `app/tests/Feature/Advertising/CreativeJobServiceTest.php` (дописать) + +- [ ] **Шаг 1: тест, который краснеет** + +```php +it('у старых заданий вид — загрузка картинок', function () { + [, $campaign] = jobCampaign(); // помощник файла + + $job = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + ]); + + expect($job->refresh()->kind)->toBe(AdCreativeJob::KIND_UPLOAD); +}); +``` + +- [ ] **Шаг 2: миграция** + +```php +Schema::table('ad_creative_jobs', function (Blueprint $table) { + // upload — отвезти картинки (то, что робот делает сейчас); + // inspect — сходить посмотреть, что кабинет говорит про объявление; + // deliver — отвезти документ клиента и отправить на модерацию. + $table->string('kind', 16)->default('upload'); + $table->foreignId('message_id')->nullable()->constrained('ad_campaign_messages')->nullOnDelete(); +}); +``` + +🪤 `->after('колонка')` на PostgreSQL — no-op, порядок колонок не меняется. Так же +написаны все соседние миграции проекта. + +Гранты не нужны — таблица уже выдана нужным ролям. Запись **v9.11** в `db/CHANGELOG_schema.md`, +`rls-reviewer` по миграции. + +🪤 Частичный уникальный индекс `uq_creative_job_single_taken` остаётся как есть: у робота +один браузерный профиль, задания идут по одному, и новые виды этого не меняют. + +- [ ] **Шаг 3: константы в модели** + +```php + public const KIND_UPLOAD = 'upload'; + + public const KIND_INSPECT = 'inspect'; + + public const KIND_DELIVER = 'deliver'; +``` + +Добавить `kind` и `message_id` в `$fillable`. + +- [ ] **Шаг 4: зелёный прогон, rls-reviewer, CHANGELOG, коммит** + +--- + +## Задача 15: разведка — робот читает кабинет + +**Файлы:** + +- Изменить: `app/app/Services/Advertising/CreativeJobService.php` — постановка задания разведки +- Изменить: `app/app/Jobs/SyncCampaignModerationJob.php` — ставить разведку при отказе +- Изменить: `app/app/Http/Controllers/Api/CreativeRobotController.php` — приём доклада +- Изменить: `bots/yandex-creatives/src/runner.js`, `src/cabinet.js` +- Тесты: `app/tests/Feature/Advertising/CreativeJobServiceTest.php`, + `app/tests/Feature/Advertising/CreativeRobotEndpointTest.php`, + `bots/yandex-creatives/test/runner.test.js` + +Точный код шагов пишется **после** задачи 13 — он зависит от снятой живьём разметки. +Требования, которые уже зафиксированы и не зависят от разметки: + +- [ ] Отказ по программному интерфейсу ставит задание разведки **ровно один раз** + на кампанию: повторный обход второго задания не создаёт +- [ ] Робот с заданием разведки **ничего не меняет** в кабинете — существующий + тест-сторож «не нажимать Сохранить» распространяется и на разведку +- [ ] Доклад разведки попадает в ленту от имени `yandex` вместе со снимком экрана +- [ ] Робот не понял, что видит — задание сбойное, письмо владельцу, **в ленту клиенту + ничего не сочиняем** +- [ ] В админке появляется список «ждёт разбора» — кампании, где робот растерялся + (замысел §4). Не для обычных отказов: обычные клиент разбирает сам +- [ ] Рубильник Директа выключен — задание не ставится и в Яндекс не ходим + +--- + +## Задача 16: доставка документа + +**Файлы:** те же, что в задаче 15, плюс `bots/yandex-creatives/src/portal.js` — скачивание +документа по адресу портала. + +Точный код — после задачи 13. Зафиксированные требования: + +- [ ] Документ клиента доезжает до кабинета и уходит на повторную модерацию +- [ ] Робот берёт файл **только с адреса портала** — существующая проверка + `assertPortalUrl` распространяется и на документы +- [ ] После доставки в ленту ложится служебная отметка «документ отправлен в Яндекс», + письма клиенту при этом нет +- [ ] Яндекс уже принял рекламу, а документ пришёл — файл сохраняем, в ленту пишем + «уже принято, документ не понадобился», робота не гоняем + +--- + +## 🛑 Конец захода 4 — кусок 3 закрыт + +- [ ] Полный прогон обоих наборов +- [ ] Приёмочный лист замысла §8, раздел «Кусок 3» и «Общее» — отметить каждую строку +- [ ] Обновить PROGRESS и STATE-файл +- [ ] Коммит по эскейпу владельца + +--- + +## Перед выкатом на боевой + +- [ ] `prod-deploy-validator` → GO +- [ ] 🔴 После миграций перезапустить `db/03_service_bypass_policies.sql` — в ветке новые + RLS-таблицы, иначе служебные роли увидят ноль **молча**, а журнал будет зелёный +- [ ] Проверить, что мест снятия заморозки денег по-прежнему четыре +- [ ] Выкат — только с явного «go» владельца