From 653b095a2acba47681bdfab8bbf19d89b9a5359d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 28 Jul 2026 12:36:34 +0300 Subject: [PATCH] =?UTF-8?q?docs=20=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC?= =?UTF-8?q?=D0=B0=20=D0=B7=D0=B0=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B:?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=85=D0=BE=D0=B4=203=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=20=E2=80=94=20=D0=BA=D1=83=D1=81=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=BE=D0=B6=D0=B8=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2,=20=D1=85=D0=BE=D0=B4=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=20=D0=B8=20=D1=81=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Advertising/CampaignLauncherTest.php | 68 +++++++++++ .../2026-07-27-PROGRESS-pochinka-v12.md | 106 ++++++++++++++++ .../2026-07-28-STATE-okno-peredachi.md | 114 +++++++++--------- 3 files changed, 232 insertions(+), 56 deletions(-) diff --git a/app/tests/Feature/Advertising/CampaignLauncherTest.php b/app/tests/Feature/Advertising/CampaignLauncherTest.php index 7ef41bfe..59b9db64 100644 --- a/app/tests/Feature/Advertising/CampaignLauncherTest.php +++ b/app/tests/Feature/Advertising/CampaignLauncherTest.php @@ -988,3 +988,71 @@ it('запуск гасит отметку об отданной на почин expect($campaign->refresh()->revived_at)->toBeNull(); }); + +/** + * Приёмочный лист замысла, кусок 2: «после правки обычная кнопка "Запустить" доводит + * кампанию до модерации». Второго пути запуска мы не делали намеренно — работает + * существующий лаунчер, который доигрывает начатое. + * + * Здесь же проверяется главное денежное следствие: в Яндексе НЕ заводится второй комплект + * кампании, группы и сегмента. Заводился бы — клиент платил бы дважды. + */ +it('после оживления обычный запуск доводит кампанию до модерации и ничего не создаёт заново', function () { + configureYandex(); + + $calls = []; + Http::fake(function ($request) use (&$calls) { + $url = $request->url(); + $key = match (true) { + str_contains($url, 'upload_csv_file') => 'segment_upload', + str_contains($url, '/confirm') => 'segment_confirm', + str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', + str_contains($url, '/json/v5/campaigns') => 'campaigns', + str_contains($url, '/json/v5/adgroups') => 'adgroups', + str_contains($url, '/json/v5/audiencetargets') => 'audiencetargets', + str_contains($url, '/json/v5/creatives') => 'creatives', + str_contains($url, '/json/v5/ads') => 'ads', + default => 'other', + }; + $calls[$key] = ($calls[$key] ?? 0) + 1; + + return match ($key) { + 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), + 'ads' => Http::response(['result' => ['AddResults' => [['Id' => 5561]]]]), + default => Http::response([], 404), + }; + }); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + // Кампания в том виде, в каком её оставляет «Исправить»: черновик с отметкой о починке, + // номера Яндекса целы, у баннера новая картинка и ни номера объявления, ни креатива нет. + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + $campaign->update([ + 'status' => AdCampaign::STATUS_DRAFT, + 'revived_at' => now(), + 'yandex_segment_id' => 900001, + 'yandex_retargeting_list_id' => 111, + 'yandex_campaign_id' => 222, + 'yandex_ad_group_id' => 333, + ]); + + app(CampaignLauncher::class)->launch($campaign); + + expect($calls)->not->toHaveKey('segment_upload') + ->and($calls)->not->toHaveKey('retargetinglists') + ->and($calls)->not->toHaveKey('campaigns') + ->and($calls)->not->toHaveKey('adgroups') + ->and($calls)->not->toHaveKey('audiencetargets') + ->and($calls['ads'] ?? 0)->toBe(1); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) + ->and($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->revived_at)->toBeNull(); + + expect(AdCampaignBanner::where('campaign_id', $campaign->id)->first()->yandex_ad_id)->toBe(5561); +}); diff --git a/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md b/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md index dd2cc6de..636d790f 100644 --- a/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md +++ b/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md @@ -680,3 +680,109 @@ v9.07. Чинить не в этой ветке. - `npm run type-check` — те же **6 чужих** ошибок, ни одной новой; - робот: **60/60**; - вызовов снятия заморозки — **четыре**, пятого не появилось. + +--- + +# Заход 3 закрыт 28.07.2026 — кусок 2 «оживление» готов, кнопка «Исправить» работает + +Задачи 9–12 плана `2026-07-28-yandex-otkazy-okno-peredachi.md`. + +**Задача 9 — удаление отклонённых объявлений** (`abb3941e`). `YandexDirectClient::deleteAds()` +шлёт `ads.delete` одним запросом со всеми номерами; пустой список наружу не уходит — +Яндекс ответил бы ошибкой на пустое дело, и мы записали бы тревогу на ровном месте. + +**Задача 10 — сервис оживления** (`729a2636`). `CampaignReviveService`: кампания +возвращается в черновик, у отклонённых баннеров обнуляются номера креатива и объявления, +номера кампании, группы и сегмента Яндекса **уцелевают**. Неудача удаления в кабинете +клиента не блокирует. При выключенном рубильнике в Яндекс не ходим вовсе. + +**Задача 11 — ручка «Исправить» и узкое исключение в замке** (`d29f45da`). `POST +.../campaigns/{id}/revive`; замок на правке настроек и на наборе баннеров получил одно +исключение — по состоянию «чинится после отказа», а не по «есть ли номер кампании». + +**Задача 12 — кнопка на экране** (`89e6a459`). «Исправить» сперва зовёт оживление, +и только потом открывает мастер. Не вышло оживить — мастер не открывается, видна ошибка: +иначе клиент упёрся бы в непонятный отказ уже внутри мастера. + +**Починка после живой проверки** (`6d34d663`). См. ниже — главная находка захода. + +## 🔴 Главная находка: живая проверка в браузере вскрыла дыру при 337 зелёных тестах + +Кнопка «Исправить» возвращает кампанию в черновик — и **в ту же секунду** исключение +из замка переставало действовать: оно было написано по статусу `rejected`, а статуса +уже не было. Замок снова смотрел только на «есть номер кампании в Яндексе» и запирал всё. + +Проверено живьём: после «Исправить» смена адреса сайта → 409 «Кампания уже заведена +в Яндексе». Перезаливка картинки — тем же признаком. **Кнопка вела в тупик, просто +на шаг дальше, чем раньше.** + +Почему тесты молчали: они проверяли правку у кампании, которая **ещё числится +отклонённой**, — а кнопка это состояние покидает немедленно. Класс ошибки: тест +проверяет состояние, в котором продукт не бывает. + +**Как починено** (решение владельца): отдельная отметка в базе `ad_campaigns.revived_at` — +«отдана клиенту на починку». Ставит `CampaignReviveService`, гасит `CampaignLauncher` +при следующем запуске. Замок открыт ровно на время починки; черновик после +**оборвавшегося** запуска отметки не получает и остаётся запертым — там сегмент +и кампания в Яндексе живы, а объявлений может не быть вовсе. + +Почему не «оставить ярлык Отклонено до нового запуска»: клиенту непонятно (нажал — +ничего не изменилось), и пришлось бы разрешать запуск прямо из «Отклонено», то есть +трогать путь запуска, где заморозка денег. + +Запись в журнале схемы — `db/CHANGELOG_schema.md`, v9.11. `rls-reviewer` — чисто: +табличные гранты покрывают новую колонку, поколоночный `UPDATE (yandex_creative_id)` +для админки к ней отношения не имеет, `db/03_service_bypass_policies.sql` перезапускать +не надо (новых таблиц нет). + +## Что проверено вырезанием + +- Отказ от пустого списка в `deleteAds` → покраснел «пустой список в Яндекс не уходит». ✅ +- Проверка статуса в сервисе оживления → покраснел «оживить можно только отклонённую». ✅ +- Обнуление номеров кампании → покраснел «номера кампании, группы и сегмента уцелевают». ✅ +- Фильтр «только отклонённые баннеры» → покраснел «принятый баннер оживление не трогает». ✅ +- Исключение в замке настроек → покраснела правка отклонённой кампании. ✅ +- Замок баннеров распахнут настежь → покраснели три теста про работающую кампанию. ✅ +- Сервис не ставит отметку `revived_at` → покраснели оба теста живой находки. ✅ +- Отметка заменена на «любой черновик» → покраснел «оборвавшийся запуск заперт». ✅ +- Запуск не гасит отметку → покраснел «запуск гасит отметку». ✅ +- Лаунчер заводит кампанию Яндекса заново → покраснел «после оживления ничего + не создаётся заново». ✅ + +## Приёмочный лист замысла §8, кусок 2 — закрыт целиком + +| Строка | Чем закрыта | +|---|---| +| «Исправить» у отклонённой возвращает её в черновик | `CampaignReviveServiceTest`, `AdvertisingCampaignEndpointTest` | +| Номера кампании, группы и сегмента уцелевают | `CampaignReviveServiceTest` | +| У отклонённых баннеров номера креатива и объявления обнулены | `CampaignReviveServiceTest` | +| Правка картинок и настроек у отклонённой разрешена | `AdvertisingCampaignEndpointTest`, `CampaignBannerEndpointsTest` — и **после нажатия «Исправить»** тоже | +| У работающей кампании правка по-прежнему заперта | `AdvertisingCampaignEndpointTest`, `CampaignBannerEndpointsTest` | +| После правки обычный «Запустить» доводит до модерации | `CampaignLauncherTest` — и ничего не создаётся в Яндексе заново | +| Второй «Исправить» получает отказ 409 | `AdvertisingCampaignEndpointTest` | +| Мест разморозки денег по-прежнему четыре | `grep -rn -- "->release(" app/app` | +| Не хватило денег — обычный отказ запуска | прежние тесты лаунчера, особого случая для оживления нет | + +## Живая проверка глазами, 28.07.2026 + +Портал поднят на тестовой базе `liderra_testing_reklama`, вход клиентом, экран +«Яндекс Аудитория»: карточка «Отклонено» с причиной → «Исправить» → кампания стала +черновиком, открылся мастер «Изменить кампанию» → смена адреса сайта прошла (200), +номера Яндекса на месте (555/666/777), у баннера номера обнулены, отметка о починке стоит. + +🪤 Синтетические клики Playwright по кнопке Vuetify не срабатывали (запрос не уходил +вовсе), а `element.click()` в самой странице — срабатывал. Проверять поведение через +`browser_evaluate`, иначе можно принять живой продукт за сломанный. + +## Зелёная отметка на конец захода 3 + +- портал: **342/342, 1133 проверки** (было 322/322 — 20 новых тестов); +- фронт: **227 файлов, 1671 тест** зелёные (посторонняя ошибка в чужом `autopodbor` — та же); +- `npm run type-check` — те же **6 чужих** ошибок, ни одной новой; +- робот: **60/60**; +- вызовов снятия заморозки — **четыре**, пятого не появилось. + +🪤 Гонять набор строго **по одному процессу**: один прогон я запустил, пока предыдущий +ещё жил после обрыва по времени, — два набора били в одну базу, и три теста +`AdminAdvertisingSpendTest` (они считают расход по ВСЕМ тенантам) покраснели на ровном +месте. По одному процессу — стабильно зелено. diff --git a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md index 797fe0ff..5177b844 100644 --- a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md +++ b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md @@ -3,28 +3,28 @@ Файл пишется в конце каждого захода. Читать его **первым делом** после компакта: всё, что нужно следующей сессии, лежит здесь, а не в переписке. -Обновлено: 28.07.2026, конец захода 2. +Обновлено: 28.07.2026, конец захода 3. --- ## Где мы - Worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. -- Последний коммит — **`fb63994a`**. -- **Заходы 1 и 2 закрыты целиком** (задачи 1–8). **Кусок 1 замысла «переписка» готов.** - Заходы 3 и 4 не начинались. +- Последний коммит кода — **`6d34d663`** (+ коммит документации следом). +- **Заходы 1, 2 и 3 закрыты целиком** (задачи 1–12). **Куски 1 «переписка» и 2 + «оживление» замысла готовы.** Заход 4 (робот) не начинался. - Никуда не пушили, на боевой не выкатывали, рубильник Директа выключен, в живой кабинет Яндекса никто не заходил. -Коммиты захода 2: +Коммиты захода 3: ```text -fb63994a test реклама за показы: в ленте не может быть наценки и пути файла на сервере -958204d2 feat реклама за показы: под ярлыком Отклонено видна первая строка причины -31709d01 feat реклама за показы: экран переписки в карточке кампании вместо мёртвого списка объявлений -27367245 feat реклама за показы: ответ клиента с документом и отдача файла только своему тенанту -5e5a9c7f feat реклама за показы: ручка списка сообщений кампании — только своя лента -ffeffe35 docs реклама за показы: заход 1 закрыт — ход работ и снимок состояния +6d34d663 fix реклама за показы: пометка отдана на починку держит правку открытой после возврата в черновик +89e6a459 feat реклама за показы: кнопка Исправить оживляет кампанию перед мастером +d29f45da feat реклама за показы: ручка Исправить и узкое исключение в замке правки +729a2636 feat реклама за показы: сервис оживления отклонённой кампании +abb3941e feat реклама за показы: удаление отклонённых объявлений в кабинете Яндекса +6bc00ba6 docs реклама за показы: заход 2 закрыт — кусок переписки готов ``` Не закоммичено — только три устаревших промта (`chast4-v3`, `chast4-v4`, @@ -32,69 +32,70 @@ ffeffe35 docs реклама за показы: заход 1 закрыт — х ## Зелёные числа на этом коммите -- портал: **322/322, 1090 проверок, ~54 с** — `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` -- фронт: **227 файлов, 1668 тестов** — `cd app && npx vitest run` +- портал: **342/342, 1133 проверки, ~57 с** — `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` +- фронт: **227 файлов, 1671 тест** — `cd app && npx vitest run` 🪤 единственная посторонняя ошибка в выводе — `autopodbor.ts::fetchProposalGroups`, чужая и давняя, к этой работе отношения не имеет. - `npm run type-check` — **6 чужих** ошибок, ни одной новой. Чужие не чинить. - робот: **60/60** — `cd bots/yandex-creatives && node --test` -- мест снятия заморозки денег — **четыре**: `AdvertisingCampaignController:392`, - `SyncCampaignModerationJob:165`, `PauseCampaignsOnAdStop:48`, `CampaignImpressionCharger:96`. +- мест снятия заморозки денег — **четыре**: `AdvertisingCampaignController`, + `SyncCampaignModerationJob`, `PauseCampaignsOnAdStop`, `CampaignImpressionCharger`. Остальные находки `->release(` — замки `Cache::lock`, не деньги. +🪤 Прогон — строго **по одному процессу**. Два набора против одной базы роняют три теста +`AdminAdvertisingSpendTest` (они считают расход по ВСЕМ тенантам) на ровном месте. + ## Что теперь работает -Круг «Яндекс → клиент → Яндекс» замкнут в одну сторону целиком: +Круг «Яндекс → клиент → Яндекс» замкнут в обе стороны, кроме доставки документов: -1. Яндекс отклоняет объявление; -2. джоб опроса модерации кладёт пояснение модератора **целиком** в `ad_campaign_messages`; -3. клиенту уходит письмо и загорается колокольчик; -4. клиент открывает карточку кампании и **видит переписку**, а в списке кампаний под - красным ярлыком «Отклонено» — первую строку причины; -5. клиент отвечает текстом и может приложить документ (pdf/jpg/png до 10 МБ); -6. документ лежит на закрытом диске и отдаётся только своему тенанту. +1. Яндекс отклоняет объявление, джоб кладёт пояснение **целиком** в переписку; +2. клиенту письмо и колокольчик, в списке под ярлыком «Отклонено» — первая строка причины; +3. клиент читает переписку в карточке кампании, отвечает и может приложить документ; +4. **клиент жмёт «Исправить»** — отклонённые объявления удаляются из кабинета, кампания + возвращается в черновик с отметкой «отдана на починку», номера кампании, группы + и сегмента Яндекса целы; +5. клиент меняет картинку и настройки привычными экранами; +6. обычная кнопка «Запустить» доводит кампанию до модерации, ничего не заводя в Яндексе + заново; отметка о починке гаснет, замок закрывается сам. -**Чего пока нет:** документ никуда не едет — робот его не носит (кусок 3), а кнопка -«Исправить» по-прежнему ведёт в тупик (кусок 2, заход 3). +**Чего пока нет:** документ клиента никуда не едет — робот его не носит, и причину +из кабинета робот не читает (кусок 3, заход 4). -## Что дальше — заход 3, задачи 9–12 +## Что дальше — заход 4, задачи 13–16 -По плану `docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md`: +🔴 **Задача 13 — снять живую разметку кабинета вместе с владельцем. Без неё заход +не начинается.** Экран с причиной отказа и экран с документами мы не видели ни разу; +код по воображаемым экранам даст зелёные тесты и мёртвую программу. Отказ можно вызвать +нарочно: залить заведомо непроходное объявление. -- **Задача 9** — `YandexDirectClient::deleteAds()`, удаление отклонённых объявлений. -- **Задача 10** — `CampaignReviveService`: возврат кампании в черновик, обнуление номеров - креатива и объявления у отклонённых баннеров; **номера кампании, группы и сегмента - уцелевают** — на них держится возобновляемый запуск. -- **Задача 11** — ручка «Исправить» и **узкое** исключение в замке правки: по статусу - `rejected`, а не по «есть ли номер кампании», иначе исключение откроет правку - и работающей рекламе. -- **Задача 12** — кнопка «Исправить» на экране. +Дальше по плану: колонка `kind` у заданий робота (`upload` / `inspect` / `deliver`), +разведка (робот читает кабинет и ничего в нём не меняет), доставка документа. -🔴 **Заход 4 не начинать, пока не снята живая разметка кабинета** (задача 13, вместе -с владельцем). Код по воображаемым экранам даст зелёные тесты и мёртвую программу. +## Свежие грабли захода 3 -## Свежие грабли захода 2 - -- 🪤 **План забыл `ensureCsrfCookie()`** в отправке ответа клиента. Все прочие POST'ы - модуля её берут; без неё кнопка «Отправить» молча ломалась бы в браузере при зелёных - тестах. Проверять это на каждой новой мутации фронта. -- 🪤 **Фильтр по тенанту на самом сообщении ничего не сторожит:** его вырезание оставило - всё зелёным, потому что настоящий замок — на поиске кампании. Урок общий: вырезание - показывает не только «защита есть», но и «эта защита ничего не держит». -- 🪤 **Мутация фронта ломает чужие тесты не там, где ждёшь.** Встроенная в диалог отчёта - лента уронила два чужих spec'а: их `vi.mock` не знал новых функций api-модуля. - Диалог теперь переживает упавшую ленту, а специи получили заглушки. -- 🪤 **Наивная отдача модели целиком утекает `file_path`** — путь файла на боевом - сервере. Ручка обязана перечислять поля вручную; тест это ловит. -- 🪤 **`\n` внутри heredoc-питона превращается в настоящий перенос строки** и ломает - `.vue` разбором «Unterminated string constant». Такие строки править инструментом Edit. +- 🔴🪤 **Тест проверял состояние, в котором продукт не бывает.** Исключение из замка + правки было написано по статусу `rejected`, а кнопка «Исправить» этот статус + немедленно покидает. 337 зелёных тестов, а живьём — тот же тупик. Поймала **только** + живая проверка в браузере. Правило на будущее: проверять состояние **после** действия + кнопки, а не до. +- 🪤 **Синтетические клики Playwright по кнопкам Vuetify не срабатывают** — запрос + не уходит вовсе, ошибки при этом нет. `element.click()` через `browser_evaluate` + срабатывает. Иначе легко принять живой продукт за сломанный. +- 🪤 **Тестовая база чистится прогоном набора** (в наборе есть `RefreshDatabase`): + засеянные для живой проверки тенант, пользователь и кампания исчезают после каждого + прогона. Сеять заново перед проверкой глазами. +- 🪤 **Последовательность `ad_campaigns.id` в тестовой базе отстаёт** — `create()` + падает на дубле ключа. Для живой проверки проще завести новый тенант, чем чинить. +- 🪤 `->after('колонка')` в миграции на PostgreSQL — **пустое место**: физически колонка + встаёт в конец таблицы. Ничего не ломает, но в журнале схемы это оговорено. +- 🪤 В worktree нет `lefthook` — каждый коммит печатает `Can't find lefthook in PATH`. + Это нормально; gitleaks гоняется руками: + `"…/Документация/bin/gitleaks.exe" protect --staged --no-banner`. - 🪤 `crm_admin_user` гранта на `ad_campaign_messages` **не получил** — когда появится админский список «ждёт разбора», нужна догоняющая миграция `GRANT SELECT` по образцу `2026_07_27_100100_grant_admin_ad_campaign_banners.php`, иначе экран увидит ноль строк молча. -- 🪤 В worktree нет `lefthook` — каждый коммит печатает `Can't find lefthook in PATH`. - Это нормально; gitleaks гоняется руками: - `"…/Документация/bin/gitleaks.exe" protect --staged --no-banner`. ## Красные линии, которые действуют всегда @@ -106,5 +107,6 @@ ffeffe35 docs реклама за показы: заход 1 закрыт — х - Коммит по эскейпу владельца, сообщение без круглых скобок, только явные пути, `LEFTHOOK_EXCLUDE=larastan`, новые файлы требуют `git add`. - 🔴 После выката на бой перезапустить `db/03_service_bypass_policies.sql` — иначе - служебные роли увидят в новой таблице ноль, а журнал будет зелёный. + служебные роли увидят в новой таблице `ad_campaign_messages` ноль, а журнал будет + зелёный. (Колонка `revived_at` перезапуска не требует — новых таблиц в заходе 3 нет.) - Открытые вопросы владельца не закрывать самому: Д4 и срок хранения чужих документов.