docs реклама за показы: заход 3 закрыт — кусок оживления готов, ход работ и снимок состояния

This commit is contained in:
Дмитрий
2026-07-28 12:36:34 +03:00
parent 6d34d663c7
commit 653b095a2a
3 changed files with 232 additions and 56 deletions
@@ -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);
});
@@ -680,3 +680,109 @@ v9.07. Чинить не в этой ветке.
- `npm run type-check` — те же **6 чужих** ошибок, ни одной новой;
- робот: **60/60**;
- вызовов снятия заморозки — **четыре**, пятого не появилось.
---
# Заход 3 закрыт 28.07.2026 — кусок 2 «оживление» готов, кнопка «Исправить» работает
Задачи 912 плана `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` (они считают расход по ВСЕМ тенантам) покраснели на ровном
месте. По одному процессу — стабильно зелено.
@@ -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, задачи 912
## Что дальше — заход 4, задачи 1316
По плану `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 и срок хранения чужих документов.