diff --git a/.gitleaks.toml b/.gitleaks.toml index 39b00739..650469d5 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -153,6 +153,11 @@ paths = [ # Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны # в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits. '''docs/superpowers/specs/.*\.md''', + # Файл-образец, который клиент скачивает перед загрузкой своего списка номеров. + # Номера синтетические — 79001234567, 79161112233, 79995551234 и т.п. На main он + # уже разрешён по коммиту 5a4c0e02, но при сведении ветки тот же текст приходит + # НОВЫМ коммитом, и разрешение по хешу его не покрывает — нужен путь. + '''app/public/examples/.*\.csv''', # Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны) '''app/resources/js/composables/mockDeals\.ts''', # Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals) diff --git a/app/.env.example b/app/.env.example index 73e0116e..d994b5f9 100644 --- a/app/.env.example +++ b/app/.env.example @@ -131,3 +131,5 @@ TG_ROBOT_NODE=node TG_ROBOT_SCRIPT= TG_ROBOT_CWD= TG_ROBOT_TIMEOUT=300 +# Канал робота-грузчика креативов в веб-кабинет Яндекса. Пусто → канал закрыт. +CREATIVE_ROBOT_TOKEN= diff --git a/app/app/Console/Commands/ReapStuckCreativeJobs.php b/app/app/Console/Commands/ReapStuckCreativeJobs.php new file mode 100644 index 00000000..642a1bbb --- /dev/null +++ b/app/app/Console/Commands/ReapStuckCreativeJobs.php @@ -0,0 +1,82 @@ +subMinutes(self::STUCK_MINUTES); + + // taken_at без значения тоже считаем зависшим: такое задание иначе не разобрать + // ничем, а в работе оно висит и очередь держит. + $stuck = AdCreativeJob::on('pgsql_admin') + ->where('status', AdCreativeJob::STATUS_TAKEN) + ->where(fn (Builder $q) => $q->whereNull('taken_at')->orWhere('taken_at', '<', $deadline)) + ->get(); + + foreach ($stuck as $job) { + if ($job->attempts >= self::MAX_ATTEMPTS) { + $job->update([ + 'status' => AdCreativeJob::STATUS_FAILED, + 'failure_reason' => sprintf( + 'Робот не отчитался за %d минут, и попытки исчерпаны (%d). Задание закрыто сторожем очереди.', + self::STUCK_MINUTES, + $job->attempts, + ), + 'finished_at' => now(), + ]); + + Log::warning('Задание робота закрыто сторожем: попытки исчерпаны', [ + 'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts, + ]); + $this->warn("Задание #{$job->id}: попытки исчерпаны, закрыто сбоем."); + + continue; + } + + $job->update(['status' => AdCreativeJob::STATUS_QUEUED, 'taken_at' => null]); + + Log::warning('Задание робота возвращено в очередь: робот не отчитался', [ + 'job_id' => $job->id, 'campaign_id' => $job->campaign_id, 'attempts' => $job->attempts, + ]); + $this->info("Задание #{$job->id}: возвращено в очередь."); + } + + if ($stuck->isEmpty()) { + $this->info('Зависших заданий нет.'); + } + + return self::SUCCESS; + } +} diff --git a/app/app/Events/AdvertisingStopped.php b/app/app/Events/AdvertisingStopped.php new file mode 100644 index 00000000..581bbce4 --- /dev/null +++ b/app/app/Events/AdvertisingStopped.php @@ -0,0 +1,22 @@ +query('period', 'current_month'); + if (! in_array($period, self::PERIODS, true)) { + $period = 'current_month'; + } + + $marginPercent = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'); + // share = (100 − ad_margin_percent) / 100 — доля client_spend, уходящая в Директ. + $share = bcdiv(bcsub('100', $marginPercent, 4), '100', 6); + + $query = DB::table('ad_wallet_transactions as w') + ->join('tenants as t', 't.id', '=', 'w.tenant_id') + ->where('w.type', 'charge') + ->where('w.channel', 'yandex'); + + $this->applyPeriod($query, 'w.created_at', $period); + + $rows = $query + ->selectRaw('w.tenant_id as tenant_id, t.organization_name as tenant_name, SUM(ABS(w.amount_rub)) as client_spend_rub') + ->groupBy('w.tenant_id', 't.organization_name') + ->havingRaw('SUM(ABS(w.amount_rub)) > 0') + ->orderByRaw('SUM(ABS(w.amount_rub)) DESC') + ->get(); + + $data = []; + $totalClientSpend = '0.00'; + $totalYandexCost = '0.00'; + $totalMargin = '0.00'; + + foreach ($rows as $r) { + // bcadd(..., '0', 2) — нормализация к money-строке scale=2, без float. + $clientSpend = bcadd((string) $r->client_spend_rub, '0', 2); + // Усечение bcmul (без округления) — безопасная сторона, см. ТЗ T4a. + $yandexCost = bcmul($clientSpend, $share, 2); + $margin = bcsub($clientSpend, $yandexCost, 2); + + $data[] = [ + 'tenant_id' => (int) $r->tenant_id, + 'tenant_name' => $r->tenant_name, + 'client_spend_rub' => $clientSpend, + 'yandex_cost_rub' => $yandexCost, + 'our_margin_rub' => $margin, + ]; + + $totalClientSpend = bcadd($totalClientSpend, $clientSpend, 2); + $totalYandexCost = bcadd($totalYandexCost, $yandexCost, 2); + $totalMargin = bcadd($totalMargin, $margin, 2); + } + + return response()->json([ + 'data' => $data, + 'totals' => [ + 'client_spend_rub' => $totalClientSpend, + 'yandex_cost_rub' => $totalYandexCost, + 'our_margin_rub' => $totalMargin, + ], + 'ad_margin_percent' => $marginPercent, + ]); + } + + /** + * Клиентская цена за 1000 показов (ad_settings.client_cpm_rub, single-row, дефолт 120.00) + * и наша наценка сверху (ad_settings.ad_margin_percent, дефолт 40.00 — клиенту НЕ видна). + */ + public function settings(): JsonResponse + { + return response()->json([ + 'client_cpm_rub' => (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00'), + 'ad_margin_percent' => (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'), + ]); + } + + public function updateSettings(Request $request): JsonResponse + { + // required_without (не «sometimes|required» на обоих) — нужно допустить обновление + // ОДНОГО из двух полей, но отклонить полностью пустой запрос (см. тест «без поля → 422»). + $v = $request->validate([ + 'client_cpm_rub' => ['required_without:ad_margin_percent', 'numeric', 'gt:0'], + 'ad_margin_percent' => ['required_without:client_cpm_rub', 'numeric', 'min:0', 'max:90'], + ]); + + $upd = []; + if (array_key_exists('client_cpm_rub', $v)) { + $upd['client_cpm_rub'] = $v['client_cpm_rub']; + } + if (array_key_exists('ad_margin_percent', $v)) { + $upd['ad_margin_percent'] = $v['ad_margin_percent']; + } + if ($upd !== []) { + DB::table('ad_settings')->update($upd); + } + + return response()->json([ + 'client_cpm_rub' => (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00'), + 'ad_margin_percent' => (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'), + ]); + } + + /** + * 8e: кампании в статусе queued (ждут номер креатива Яндекса) по всем тенантам — + * оператор оформляет адаптивный креатив в конструкторе Яндекса и вписывает номер + * (см. setCampaignCreative), после чего кампанию можно запускать (CampaignLauncher). + */ + public function campaignsAwaiting(): JsonResponse + { + $rows = DB::table('ad_campaigns as c') + ->join('tenants as t', 't.id', '=', 'c.tenant_id') + ->where('c.status', AdCampaign::STATUS_QUEUED) + ->orderByDesc('c.id') + ->select([ + 'c.id', + 'c.tenant_id', + 't.organization_name as tenant_name', + 'c.name', + 'c.status', + 'c.yandex_creative_id', + 'c.landing_url', + 'c.estimated_impressions', + ]) + ->get(); + + $data = $rows->map(fn ($r) => [ + 'id' => (int) $r->id, + 'tenant_id' => (int) $r->tenant_id, + 'tenant_name' => $r->tenant_name, + 'name' => $r->name, + 'status' => $r->status, + 'yandex_creative_id' => $r->yandex_creative_id !== null ? (int) $r->yandex_creative_id : null, + 'landing_url' => $r->landing_url, + 'estimated_impressions' => $r->estimated_impressions !== null ? (int) $r->estimated_impressions : null, + ])->values(); + + return response()->json(['data' => $data]); + } + + /** + * Кампании, где робот сходил в кабинет и НЕ ПОНЯЛ, что видит: сбойные задания. + * + * 🔴 Это не список обычных отказов — их клиент разбирает сам, по причине в переписке. + * Это список мест, где ЦЕПОЧКА ВСТАЛА: разметка кабинета поменялась, страница не + * открылась, объявления в списке не нашлось. Без такого экрана обрыв тихий: клиент + * ждёт ответа, которого не будет, и никто об этом не узнает. + * + * Показываем все виды заданий, а не только разведку: сорванная заливка картинок — тоже + * вставшая кампания, и владельцу её видеть надо. + */ + public function robotStuck(): JsonResponse + { + $rows = DB::table('ad_creative_jobs as j') + ->join('ad_campaigns as c', 'c.id', '=', 'j.campaign_id') + ->join('tenants as t', 't.id', '=', 'j.tenant_id') + ->where('j.status', AdCreativeJob::STATUS_FAILED) + ->orderByDesc('j.id') + ->limit(200) + ->select([ + 'j.id', + 'j.campaign_id', + 'j.tenant_id', + 'j.kind', + 'j.yandex_ad_id', + 'j.failure_reason', + 'j.attempts', + 'j.finished_at', + 'c.name as campaign_name', + 'c.status as campaign_status', + 't.organization_name as tenant_name', + ]) + ->get(); + + $data = $rows->map(fn ($r) => [ + 'id' => (int) $r->id, + 'campaign_id' => (int) $r->campaign_id, + 'tenant_id' => (int) $r->tenant_id, + 'tenant_name' => $r->tenant_name, + 'campaign_name' => $r->campaign_name, + 'campaign_status' => $r->campaign_status, + 'kind' => $r->kind, + 'yandex_ad_id' => $r->yandex_ad_id !== null ? (int) $r->yandex_ad_id : null, + 'failure_reason' => $r->failure_reason, + 'attempts' => (int) $r->attempts, + 'finished_at' => $r->finished_at, + ])->values(); + + return response()->json(['data' => $data]); + } + + /** + * Вписать номер оформленного в Яндекс.Директе адаптивного креатива кампании. + * + * ⚠️ Колоночный GRANT на ad_campaigns у crm_admin_user разрешает UPDATE ТОЛЬКО + * yandex_creative_id — обновляем строго через query-builder (не Eloquent save()), + * иначе Eloquent попытается тронуть updated_at/другие колонки и упадёт по гранту. + */ + public function setCampaignCreative(Request $request, int $id): JsonResponse + { + $data = $request->validate([ + 'yandex_creative_id' => ['required', 'integer', 'min:1'], + ]); + + // DB::table (сырой query-builder), НЕ Eloquent: Eloquent-билдер добавил бы + // updated_at, а колоночный GRANT разрешает crm_admin_user писать ТОЛЬКО + // yandex_creative_id → на проде UPDATE с updated_at упал бы «permission denied». + $updated = DB::table('ad_campaigns')->where('id', $id)->update([ + 'yandex_creative_id' => $data['yandex_creative_id'], + ]); + + if ($updated === 0) { + return response()->json(['message' => 'Кампания не найдена.'], 404); + } + + return response()->json([ + 'id' => $id, + 'yandex_creative_id' => $data['yandex_creative_id'], + ]); + } + + /** Зеркало TenantChargesController::applyPeriodTo — Europe/Moscow, тот же набор периодов. */ + private function applyPeriod($query, string $column, string $period): void + { + $now = Carbon::now('Europe/Moscow'); + + if ($period === 'current_month') { + $query->where($column, '>=', $now->copy()->startOfMonth()); + } elseif ($period === 'last_month') { + $query->whereBetween($column, [ + $now->copy()->subMonth()->startOfMonth(), + $now->copy()->subMonth()->endOfMonth(), + ]); + } elseif ($period === '90d') { + $query->where($column, '>=', $now->copy()->subDays(90)); + } + // 'all' — без фильтра по дате. + } +} diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php new file mode 100644 index 00000000..28a9451e --- /dev/null +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php @@ -0,0 +1,947 @@ +user()->tenant_id; все запросы к ad_* — с явным + * ->where('tenant_id', ...) поверх RLS (defense-in-depth, в тестах PG superuser + * BYPASSRLS) — паттерн из AdvertisingWalletController/TenantChargesController. + */ +class AdvertisingCampaignController extends Controller +{ + public function index(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaigns = AdCampaign::where('tenant_id', $tenantId) + ->orderByDesc('id') + // moderation_reason — подпись под красным ярлыком «Отклонено»: без неё + // клиент видел отказ и ни слова о том, что переделывать. Наценку + // (yandex_cost_rub, ad_margin_percent) сюда не добавлять никогда. + ->get(['id', 'name', 'status', 'audience_days', 'frequency', 'estimated_impressions', 'budget_rub', 'launched_at', 'moderation_reason']); + + return response()->json(['data' => $campaigns]); + } + + public function store(Request $request): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'audience_days' => ['required', 'integer', 'min:1', 'max:90'], + 'use_uploaded_list' => ['boolean'], + 'frequency' => ['nullable', 'integer', 'min:1', 'max:1000'], + 'frequency_period_days' => ['nullable', 'integer', 'min:1', 'max:90'], + 'estimated_impressions' => ['nullable', 'integer', 'min:0'], + 'budget_rub' => ['nullable', 'numeric', 'min:0'], + 'mode' => ['sometimes', 'in:auto,manual'], + 'snapshot_from' => ['nullable', 'date'], + 'snapshot_to' => ['nullable', 'date', 'after_or_equal:snapshot_from'], + 'run_days' => ['nullable', 'integer', 'min:1', 'max:365'], + 'client_cpm_rub' => ['nullable', 'numeric', 'gt:0'], + 'landing_url' => ['nullable', 'url', 'max:1024'], + ]); + + $mode = $data['mode'] ?? AdCampaign::MODE_AUTO; + + $campaign = AdCampaign::create([ + 'tenant_id' => $tenantId, + 'status' => AdCampaign::STATUS_DRAFT, + 'name' => $data['name'], + 'mode' => $mode, + 'audience_days' => $data['audience_days'], + 'snapshot_from' => $data['snapshot_from'] ?? null, + 'snapshot_to' => $data['snapshot_to'] ?? null, + 'run_days' => $data['run_days'] ?? null, + // Свой список номеров — только в ручном режиме (Ф-правило); в авто форсим false + // независимо от того, что пришло в запросе. + 'use_uploaded_list' => $mode === AdCampaign::MODE_MANUAL ? ($data['use_uploaded_list'] ?? false) : false, + 'frequency' => $data['frequency'] ?? null, + 'frequency_period_days' => $data['frequency_period_days'] ?? null, + 'estimated_impressions' => $data['estimated_impressions'] ?? null, + 'budget_rub' => $data['budget_rub'] ?? null, + 'client_cpm_rub' => $data['client_cpm_rub'] ?? null, + 'landing_url' => $data['landing_url'] ?? null, + ]); + + return response()->json($campaign, 201); + } + + public function show(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + $ads = AdCampaignAd::where('tenant_id', $tenantId)->where('campaign_id', $campaign->id)->get(); + + $chargeAmounts = AdWalletTransaction::where('tenant_id', $tenantId) + ->where('type', AdWalletTransaction::TYPE_CHARGE) + ->where('channel', 'yandex') + ->where('related_type', 'campaign') + ->where('related_id', $campaign->id) + ->pluck('amount_rub'); + + $spentRub = '0.00'; + foreach ($chargeAmounts as $amount) { + $amount = (string) $amount; + $abs = bccomp($amount, '0', 2) < 0 ? bcmul($amount, '-1', 2) : $amount; + $spentRub = bcadd($spentRub, $abs, 2); + } + + return response()->json([ + 'campaign' => $campaign, + 'ads' => $ads, + 'spent_rub' => $spentRub, + ]); + } + + public function update(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $data = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'audience_days' => ['sometimes', 'required', 'integer', 'min:1', 'max:90'], + 'use_uploaded_list' => ['sometimes', 'boolean'], + 'frequency' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:1000'], + 'frequency_period_days' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:90'], + 'estimated_impressions' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'budget_rub' => ['sometimes', 'nullable', 'numeric', 'min:0'], + 'mode' => ['sometimes', 'in:auto,manual'], + 'snapshot_from' => ['sometimes', 'nullable', 'date'], + 'snapshot_to' => ['sometimes', 'nullable', 'date', 'after_or_equal:snapshot_from'], + 'run_days' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:365'], + 'client_cpm_rub' => ['sometimes', 'nullable', 'numeric', 'gt:0'], + 'landing_url' => ['sometimes', 'nullable', 'url', 'max:1024'], + ]); + + // ЗАМОК: как только в Яндексе что-то заведено, параметры показа править нельзя — + // менять можно только название (на Яндекс и на деньги оно не влияет). + // + // Замок по номерам созданных в Яндексе сущностей, а НЕ по статусу: если запуск + // оборвался на середине, статус так и остался draft, но кампания, группа и часть + // объявлений в кабинете Яндекса уже созданы — по статусу такую кампанию не отличить + // от нетронутого черновика. Номера сущностей — единственный честный признак «там уже есть». + // + // Сегмент Яндекс.Аудиторий создаётся РАНЬШЕ кампании Директа, поэтому одного + // yandex_campaign_id мало: обрыв ровно в этом окне оставлял настройки аудитории + // открытыми. Клиент менял срок сбора или список номеров, портал показывал новое — + // а возобновлённый запуск переиспользовал СТАРЫЙ сегмент, и реклама шла по прежним + // телефонам. Молча. + // + // Что ломала правка без замка: в Яндексе остаются старые AverageCpm / SpendLimit / + // даты, а портал показывает новые; paid_impressions (потолок биллинга, пишется при + // запуске) расходится с тем, что видит клиент. Отдельно landing_url — он уходит в + // Яндекс адресом перехода по клику у КАЖДОГО объявления: смена посреди возобновляемого + // запуска развела бы объявления по разным адресам (часть со старым, часть с новым). + // + // ОДНО узкое исключение: кампанию, которую Яндекс отклонил, клиент обязан иметь + // возможность починить. Показов у неё нет и денег на ней нет — расходиться + // с Яндексом нечему. + // + // 🪤 Исключение написано по отказу и отметке «отдана на починку», а НЕ по «есть ли + // номер кампании»: иначе оно открыло бы правку работающей рекламе, которая крутится + // за деньги клиента. + // 🪤 Одного статуса `rejected` мало: «Исправить» тут же переводит кампанию + // в черновик, и исключение по статусу гасло в ту же секунду — мастер открывался, + // а сервер правку не пускал. Поймано живой проверкой в браузере 28.07.2026. + // Черновик после ОБОРВАВШЕГОСЯ запуска отметки не имеет и остаётся запертым. + $editable = array_diff(array_keys($data), ['name']); + $inYandex = ($campaign->yandex_campaign_id !== null || $campaign->yandex_segment_id !== null) + && ! $this->underRepair($campaign); + if ($inYandex && $editable !== []) { + return response()->json([ + 'message' => 'Кампания уже заведена в Яндексе — менять параметры показа нельзя. Название поменять можно.', + ], 409); + } + + // Свой список номеров — только в ручном режиме. Итоговый режим = то, что пришло в + // запросе, иначе — текущий режим кампании. Если итог auto — форсим use_uploaded_list=false, + // даже если это поле в запросе не пришло (не затираем остальные несогласованные поля). + $effectiveMode = $data['mode'] ?? $campaign->mode; + if ($effectiveMode === AdCampaign::MODE_AUTO) { + $data['use_uploaded_list'] = false; + } + + // Правка аудитории/списка (audience_days, use_uploaded_list) применяется на + // следующий день ночным replace-джобом (Р30) — здесь только сохраняем поле, + // текущий прогон кампании её не подхватывает. Бюджет — применяется сразу. + $campaign->update($data); + + return response()->json($campaign->fresh()); + } + + /** + * T15 — удаление черновика. Только status=draft своего тенанта; running/paused/… — + * отказ 409 (менеджер не должен молча терять запущенную кампанию). Дочерние + * ad_campaign_ads/ad_campaign_phones — cascadeOnDelete на уровне схемы. + */ + public function destroy(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if ($campaign->status !== AdCampaign::STATUS_DRAFT) { + return response()->json([ + 'message' => 'Удалить можно только черновик кампании.', + ], 409); + } + + $campaign->delete(); + + return response()->json(null, 204); + } + + public function audienceSize(Request $request, int $id, CampaignAudienceBuilder $builder, CampaignEstimateService $estimator): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $data = $request->validate([ + 'days' => ['nullable', 'integer', 'min:1', 'max:90'], + 'frequency' => ['nullable', 'integer', 'min:1', 'max:1000'], + 'mode' => ['nullable', 'in:auto,manual'], + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'cpm' => ['nullable', 'numeric', 'gt:0'], + ]); + + // Живой счётчик (Р22-Р23): временно проставляем режим/даты/дни на инстансе, + // НЕ сохраняя в БД (нет вызова ->save()) — мастер ещё правит черновик. + if (isset($data['mode'])) { + $campaign->mode = $data['mode']; + } + $campaign->audience_days = $data['days'] ?? $campaign->audience_days; + if (isset($data['from'])) { + $campaign->snapshot_from = $data['from']; + } + if (isset($data['to'])) { + $campaign->snapshot_to = $data['to']; + } + + $size = $builder->size($campaign); + $enough = $size >= 100; + + $payload = [ + 'size' => $size, + 'min' => 100, + 'enough' => $enough, + 'hint' => $enough ? null : 'Аудитория меньше 100 — увеличьте число дней или добавьте свой список', + ]; + + if (isset($data['frequency'])) { + $cpm = isset($data['cpm']) ? number_format((float) $data['cpm'], 2, '.', '') : $campaign->effectiveCpm(); + $est = $estimator->estimate($size, (int) $data['frequency'], $cpm); + $payload['frequency'] = $est['frequency']; + $payload['impressions'] = $est['impressions']; + $payload['cpm_rub'] = $est['cpm_rub']; + $payload['cost_rub'] = $est['cost_rub']; + } + + return response()->json($payload); + } + + public function launch(Request $request, int $id, CampaignLauncher $launcher): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + // Креативы в кабинет Яндекса заливает робот-грузчик: картиночный креатив через API + // не создать (findings 2026-07-27). Если номеров ещё нет — это не ошибка, а «подождите»: + // ставим задание роботу и отвечаем 202. Кампания остаётся черновиком, деньги не морозятся, + // в Яндексе ничего не создаётся. + // + // Рубильник Директа проверяем ЗДЕСЬ, до постановки задания: enqueue() ходит в API Яндекса + // за слепком креативов. Раньше рубильник проверял только CampaignLauncher, и эта ветка + // проскочила бы мимо него — при выключенном рубильнике портал полез бы в живой Яндекс. + // Если рубильник выключен — просто идём дальше, launcher отдаст прежний отказ 409. + if (config('services.yandex_direct.enabled')) { + $needCreatives = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where('included', true) + ->whereNull('yandex_creative_id') + ->exists(); + + if ($needCreatives) { + try { + app(CreativeJobService::class)->enqueue($campaign); + } catch (Throwable $e) { + // Постановка задания ходит в живой Яндекс за слепком креативов, и слепок + // обязателен: без него потом не опознать, какие креативы залил робот. + // Яндекс лёг — раньше клиент получал голый 500 «что-то пошло не так» + // и не понимал, виноват ли он и надо ли заливать картинки заново. + // Отвечаем честно: это не вы, попробуйте позже. Кампания остаётся + // черновиком, задание не создаётся, деньги не трогаются. + Log::error('Не смогли поставить задание роботу на креативы', [ + 'campaign_id' => $campaign->id, + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'status' => 'yandex_unavailable', + 'message' => 'Рекламный кабинет Яндекса сейчас не отвечает — запустить не получилось. Ваши картинки и настройки сохранены, попробуйте ещё раз через несколько минут.', + ], 503); + } + + return response()->json([ + 'status' => 'creatives_pending', + 'message' => 'Готовим картинки в рекламном кабинете. Обычно занимает несколько минут — попробуйте запустить чуть позже.', + ], 202); + } + } + + try { + $launcher->launch($campaign); + } catch (AudienceTooSmallException $e) { + return response()->json([ + 'message' => "Аудитория слишком мала: {$e->size}. Нужно минимум 100 — увеличьте дни или добавьте свой список.", + ], 422); + } catch (RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 409); + } + + return response()->json(['status' => $campaign->fresh()->status]); + } + + /** + * «Исправить» у отклонённой кампании — вернуть её в черновик, чтобы клиент переделал + * картинки привычными экранами и нажал обычную кнопку «Запустить». Второго пути + * запуска не появляется: дальше работает существующий CampaignLauncher. + * + * Второе нажатие видит кампанию уже черновиком и получает отказ 409 — оно стёрло бы + * номера объявлений, которые к тому моменту мог создать новый запуск. + */ + 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]); + } + + /** + * Ч.5b — «отправить заявку на запуск». Пока Директ закрыт (заявка на доступ на рассмотрении), + * мастер не запускает кампанию в Директе (это Часть 4), а переводит готовую кампанию в статус + * `queued` — «готова к запуску, ждёт оператора». Требует: утверждённые баннеры + заданную частоту. + * Реальный запуск/заморозку денег доделает Часть 4. + */ + public function submit(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if ($campaign->banners_approved_at === null) { + return response()->json(['message' => 'Сначала утвердите баннеры.'], 422); + } + // Защита от «пустого показа»: галочку «в показ» могли снять уже ПОСЛЕ утверждения + // (utverждение не сбрасывается при переключении) — на момент отправки нужен хотя бы один включённый. + $hasIncluded = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id)->where('included', true)->exists(); + if (! $hasIncluded) { + return response()->json(['message' => 'Ни один баннер не отмечен «в показ» — отметьте хотя бы один и утвердите заново.'], 422); + } + if ($campaign->frequency === null || $campaign->estimated_impressions === null) { + return response()->json(['message' => 'Не заданы частота и смета показов.'], 422); + } + if ($campaign->mode === AdCampaign::MODE_MANUAL + && ($campaign->snapshot_from === null || $campaign->snapshot_to === null || $campaign->run_days === null)) { + return response()->json(['message' => 'Для ручного режима задайте период дат и срок показа.'], 422); + } + + $campaign->update(['status' => AdCampaign::STATUS_QUEUED]); + + return response()->json(['status' => $campaign->fresh()->status]); + } + + public function pause(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if (! in_array($campaign->status, [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION], true)) { + return response()->json([ + 'message' => 'Кампанию нельзя поставить на паузу из текущего состояния.', + ], 409); + } + + // Пауза, которая не дошла до Директа, — не пауза. Раньше ошибку глотали в журнал, + // ставили статус «на паузе» и БЕЗУСЛОВНО размораживали деньги: реклама в Яндексе + // продолжала крутиться и тратить, портал показывал «на паузе», а деньги за неё уже + // были свободны и могли уйти на другую кампанию. Клиент уходил в минус молча. + $error = $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->suspendCampaign($yandexCampaignId)); + if ($error !== null) { + return response()->json([ + 'message' => 'Не удалось остановить рекламу в Яндексе — попробуйте ещё раз через минуту. Кампания продолжает работать, деньги под неё зарезервированы.', + ], 409); + } + + $campaign->update(['status' => AdCampaign::STATUS_PAUSED]); + + // ВЫХОД 4 — на паузе кампания не крутится и не тратит деньги, поэтому держать + // их зарезервированными незачем: возвращаем в свободные, клиент волен пустить + // их на другую рекламу. При возобновлении остаток сметы морозится заново. + app(AdWalletService::class)->release($tenantId, 'yandex', 'campaign', (int) $campaign->id); + + return response()->json(['status' => $campaign->fresh()->status]); + } + + public function resume(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if ($campaign->status !== AdCampaign::STATUS_PAUSED) { + return response()->json([ + 'message' => 'Возобновить можно только кампанию на паузе.', + ], 409); + } + + // ВЫХОД 4 (обратно) — резервируем неоткрученный остаток сметы ДО обращения к + // Директу: если денег не хватает, кампания не должна ожить в Яндексе. + $remaining = $this->remainingBudgetRub($campaign); + if (bccomp($remaining, '0.00', 2) > 0) { + try { + app(AdWalletService::class)->freeze($tenantId, 'yandex', 'campaign', (int) $campaign->id, $remaining); + } catch (InsufficientBalanceException) { + return response()->json([ + 'message' => 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.', + ], 409); + } + } + + $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->resumeCampaign($yandexCampaignId)); + + $campaign->update(['status' => AdCampaign::STATUS_RUNNING]); + + return response()->json(['status' => $campaign->fresh()->status]); + } + + /** + * Неоткрученный остаток сметы в клиентских рублях: полная стоимость оплаченных + * показов минус уже списанное. Для кампаний без сметы (старые, «за клики») — 0. + */ + private function remainingBudgetRub(AdCampaign $campaign): string + { + $paid = (int) ($campaign->paid_impressions ?? 0); + if ($paid <= 0) { + return '0.00'; + } + + $total = app(AdImpressionPricing::class)->clientCostRub($paid, $campaign->effectiveCpm()); + $rest = bcsub($total, (string) ($campaign->charged_client_rub ?? '0.00'), 2); + + return bccomp($rest, '0.00', 2) > 0 ? $rest : '0.00'; + } + + /** + * Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть + * yandex_campaign_id. Деньги не трогает. + * + * Возвращает null при успехе (в том числе когда идти в Директ не нужно вовсе) либо + * текст ошибки. Решать, что делать с неудачей, — задача вызывающего: для паузы она + * критична (иначе реклама крутится, а деньги уже разморожены), для возобновления — + * нет: там деньги заморожены заранее, и клиент увидит кампанию работающей, даже если + * до Яндекса мы не достучались. Отказывать на возобновлении опаснее: заморозка уже + * стоит, а снять её обратно можно было бы только новым местом разморозки — а их + * в системе ровно четыре и пятое заводить нельзя. + */ + private function callDirect(AdCampaign $campaign, callable $action): ?string + { + // 🪤 Была проверка `!== true`. Рубильник приходит из env строкой, и «1» в .env + // читалась бы здесь как «выключено»: в Директ мы бы не пошли, а пауза приняла бы + // это за успех и разморозила деньги — при работающей в Яндексе рекламе. + if (! config('services.yandex_direct.enabled') || $campaign->yandex_campaign_id === null) { + return null; + } + + $direct = new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + + try { + $action($direct, (int) $campaign->yandex_campaign_id); + } catch (RuntimeException $e) { + Log::warning('advertising.campaign_direct_call_failed', [ + 'campaign_id' => $campaign->id, + 'error' => $e->getMessage(), + ]); + + return $e->getMessage(); + } + + return null; + } + + public function storeAd(Request $request, int $id, CreativeValidator $validator): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:56'], + 'text' => ['required', 'string', 'max:120'], + 'href' => ['required', 'url', 'max:1024'], + 'title2' => ['nullable', 'string', 'max:60'], + ]); + + $errors = $validator->validateText($data['title'], $data['text'], $data['title2'] ?? null); + if ($errors !== []) { + return response()->json(['errors' => $errors], 422); + } + + $ad = AdCampaignAd::create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'title' => $data['title'], + 'text' => $data['text'], + 'href' => $data['href'], + 'title2' => $data['title2'] ?? null, + 'moderation_status' => 'draft', + ]); + + return response()->json($ad, 201); + } + + public function uploadAdImage(Request $request, int $id, int $adId, CreativeValidator $validator): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + $ad = AdCampaignAd::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where('id', $adId) + ->firstOrFail(); + + $request->validate([ + 'file' => ['required', 'image', 'max:10240'], + ]); + + /** @var UploadedFile $file */ + $file = $request->file('file'); + $path = $file->getRealPath() ?: $file->getPathname(); + + $size = @getimagesize($path); + $width = (int) ($size[0] ?? 0); + $height = (int) ($size[1] ?? 0); + + $errors = $validator->validateImage($width, $height, (string) $file->getMimeType(), (int) $file->getSize()); + if ($errors !== []) { + return response()->json(['errors' => $errors], 422); + } + + // 🪤 Была проверка `=== false`. Значение приходит из env, и `YANDEX_DIRECT_ENABLED=0` + // в .env даёт СТРОКУ «0»: рубильник считает её выключенным, а строгое сравнение — + // включённым, и запрос уходил бы в живой Яндекс при выключенном рубильнике. + // Остальные места проверяют именно так — приводим к общему виду. + if (! config('services.yandex_direct.enabled')) { + return response()->json(['message' => 'Яндекс.Директ выключен — картинку пока не загрузить.'], 409); + } + + $direct = new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + + $base64 = base64_encode((string) file_get_contents($path)); + $hash = $direct->uploadAdImage($file->getClientOriginalName(), $base64); + + $ad->update(['image_normal_hash' => $hash]); + + return response()->json(['hash' => $hash]); + } + + /** + * T17 — загрузка «моего списка» номеров. Тело: либо `file` (csv/txt — обычный + * текст, номера построчно), либо `text` (номера через перевод строки/запятую). + * xlsx НЕ поддерживаем — новую зависимость не тянем сверх уже установленного + * phpspreadsheet, а его подключение под xlsx — отдельная задача. + * + * Нормализация — PhoneNormalizer::normalize() (формат «+7XXXXXXXXXX»), храним + * без «+» («79XXXXXXXXX» — как в остальных телефонах/Яндекс.Аудиториях). + * Невалидные строки отбрасываем, дубли схлопываем перед вставкой; unique + * (tenant_id, campaign_id, phone) на таблице закрывает повторные заливки — + * insertOrIgnore, чтобы повтор не падал по ключу. + */ + public function storePhones(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $data = $request->validate([ + 'file' => ['nullable', 'file', 'mimes:csv,txt', 'max:5120'], + 'text' => ['nullable', 'string'], + ]); + + // «Тихий ноль» (Ф3): без этой проверки пустая отправка (ни файла, ни текста) + // тихо считается «успехом» с recognized=0 — клиент не понимает, что ничего + // не загрузилось. Отдаём явный 422 с понятным сообщением. + $hasFile = $request->hasFile('file'); + $hasText = trim((string) ($data['text'] ?? '')) !== ''; + if (! $hasFile && ! $hasText) { + return response()->json([ + 'message' => 'Загрузите файл или вставьте номера', + ], 422); + } + + $raw = ''; + if ($request->hasFile('file')) { + /** @var UploadedFile $file */ + $file = $request->file('file'); + $raw .= (string) file_get_contents($file->getRealPath() ?: $file->getPathname()); + } + if (($data['text'] ?? '') !== '') { + $raw .= "\n".$data['text']; + } + + $lines = preg_split('/[\r\n,;]+/', $raw) ?: []; + + $recognized = 0; + $skipped = 0; + $unique = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + $normalized = PhoneNormalizer::normalize($line); + if ($normalized === null) { + $skipped++; + + continue; + } + + $phone = substr($normalized, 1); // "+7XXXXXXXXXX" → "7XXXXXXXXXX" + if (isset($unique[$phone])) { + $skipped++; + + continue; + } + $unique[$phone] = true; + $recognized++; + } + + if ($unique !== []) { + $now = now(); + $rows = array_map(fn (string $phone) => [ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'phone' => $phone, + 'created_at' => $now, + 'updated_at' => $now, + ], array_keys($unique)); + + DB::table('ad_campaign_phones')->insertOrIgnore($rows); + } + + return response()->json([ + 'recognized' => $recognized, + 'skipped' => $skipped, + ]); + } + + /** + * Новая модель: клиент грузит СВОЙ готовый баннер на КАЖДЫЙ размер (не + * автогенерация из одной картинки) — HANDOFF reklama-yandex-ux-audit. + * `slots` — все 15 размеров BannerSizes::all() по порядку; для загруженных + * заполнены uploaded/banner_id/bytes/included/preview_url. + */ + public function listBanners(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + return response()->json([ + 'approved_at' => $campaign->banners_approved_at, + 'max_bytes' => BannerUploadPolicy::MAX_BYTES, + 'formats' => BannerUploadPolicy::FORMATS, + 'slots' => $this->bannerSlots($campaign), + ]); + } + + public function uploadBanner(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if (($locked = $this->bannersLocked($campaign)) !== null) { + return $locked; + } + + $validated = $request->validate([ + 'width' => ['required', 'integer'], + 'height' => ['required', 'integer'], + 'file' => ['required', 'file', 'mimes:'.implode(',', BannerUploadPolicy::FORMATS), 'max:'.(int) (BannerUploadPolicy::MAX_BYTES / 1024)], + ]); + + $w = (int) $validated['width']; + $h = (int) $validated['height']; + if (! in_array([$w, $h], BannerSizes::all(), true)) { + return response()->json(['message' => 'Неизвестный размер баннера.'], 422); + } + + /** @var UploadedFile $file */ + $file = $request->file('file'); + $realPath = $file->getRealPath() ?: $file->getPathname(); + [$imgW, $imgH] = @getimagesize($realPath) ?: [0, 0]; + if ($imgW !== $w || $imgH !== $h) { + return response()->json(['message' => "Нужен ровно {$w}×{$h}. Вы загрузили {$imgW}×{$imgH}."], 422); + } + + $binary = (string) file_get_contents($realPath); + // Расширение — из ПРОВАЛИДИРОВАННОГО содержимого (guessed по MIME), НЕ из имени файла клиента + // (иначе клиент задаёт произвольное расширение на диске — риск полиглот-файла). + $guessed = $file->extension(); + $ext = in_array($guessed, BannerUploadPolicy::FORMATS, true) ? $guessed : 'jpg'; + $path = "ad-banners/{$tenantId}/{$campaign->id}/{$w}x{$h}.{$ext}"; + + $disk = Storage::disk('local'); + $existing = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where('width', $w)->where('height', $h) + ->first(); + if ($existing) { + $disk->delete($existing->path); + } + $disk->put($path, $binary); + + // Номер креатива обнуляем вместе с картинкой. Робот мог уже отвезти прежнюю картинку + // в кабинет Яндекса и проставить номер; сюда мы попадаем только пока кампании в Директе + // нет (иначе набор заперт), объявлений тоже нет — но запуск взял бы СТАРЫЙ номер, и + // клиент заплатил бы за показы картинки, которую сам же и заменил. Обнулённый номер + // означает «нужен новый заход робота» — ровно то, что и требуется. + $banner = AdCampaignBanner::updateOrCreate( + ['tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'width' => $w, 'height' => $h], + ['path' => $path, 'bytes' => strlen($binary), 'included' => $existing->included ?? true, 'yandex_creative_id' => null], + ); + + $campaign->update(['banners_approved_at' => null]); + + return response()->json(['slot' => $this->bannerSlot($campaign, $w, $h, $banner)], 201); + } + + public function toggleBannerIncluded(Request $request, int $id, int $bannerId): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + $banner = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id)->where('id', $bannerId)->firstOrFail(); + + if (($locked = $this->bannersLocked($campaign)) !== null) { + return $locked; + } + + $data = $request->validate(['included' => ['required', 'boolean']]); + $banner->update(['included' => $data['included']]); + + return response()->json(['slot' => $this->bannerSlot($campaign, (int) $banner->width, (int) $banner->height, $banner->fresh())]); + } + + public function deleteBanner(Request $request, int $id, int $bannerId): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + $banner = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id)->where('id', $bannerId)->firstOrFail(); + + // У баннера есть номер объявления в Яндексе — значит объявление создано и крутится. + // Удалить строку = забыть про живое объявление: портал перестанет его видеть, а + // возобновляемый запуск заведёт ВТОРОЕ того же размера. Запрещаем всегда, даже если + // номер кампании почему-то пуст. + if ($banner->yandex_ad_id !== null) { + return response()->json([ + 'message' => 'По этому размеру в Яндексе уже создано объявление — удалить баннер нельзя.', + ], 409); + } + + if (($locked = $this->bannersLocked($campaign)) !== null) { + return $locked; + } + + Storage::disk('local')->delete($banner->path); + $banner->delete(); + $campaign->update(['banners_approved_at' => null]); + + return response()->json(null, 204); + } + + /** + * Замок на баннерах: кампания заведена в Яндексе — набор картинок трогать нельзя. + * Возвращает готовый ответ 409, либо null, если правка разрешена. + * + * Признак тот же, что и у замка на параметрах кампании (см. update()) — `yandex_campaign_id`, + * а НЕ статус: оборвавшийся запуск оставляет статус `draft`, хотя кампания, группа и часть + * объявлений в кабинете уже созданы, и по статусу такую кампанию от нетронутого черновика + * не отличить. + * + * Что ломалось без замка: + * - перезаливка картинки: строка баннера обновляется, а `yandex_creative_id`/`yandex_ad_id` + * остаются от старого креатива — в портале новая картинка, в Яндексе крутится старая, молча; + * - удаление и повторная заливка: строка с `yandex_ad_id` уничтожалась, возобновление видело + * «номера объявления нет» и создавало второе объявление того же размера, а старое + * продолжало крутиться за деньги клиента. + */ + /** + * Кампанию сейчас чинят после отказа Яндекса — правка ей разрешена. + * + * Два состояния одного и того же: Яндекс только что отклонил (`rejected`) либо клиент + * уже нажал «Исправить» и кампания вернулась в черновик с отметкой `revived_at`. + * Отметка гаснет при следующем запуске — замок закрывается сам. + */ + private function underRepair(AdCampaign $campaign): bool + { + return $campaign->status === AdCampaign::STATUS_REJECTED + || ($campaign->status === AdCampaign::STATUS_DRAFT && $campaign->revived_at !== null); + } + + private function bannersLocked(AdCampaign $campaign): ?JsonResponse + { + if ($campaign->yandex_campaign_id === null) { + return null; + } + + // То же узкое исключение, что и у замка на параметрах (см. update()): кампании, + // которую чинят после отказа, нужна новая картинка — иначе кнопка «Исправить» + // ведёт в тупик. Показов у неё нет, деньги вернулись клиенту при отказе. + // + // Отдельная защита баннера с собственным `yandex_ad_id` (см. deleteBanner) + // остаётся на месте и после оживления снимается сама: сервис оживления обнуляет + // номера только у отклонённых баннеров, а принятые не трогает. + if ($this->underRepair($campaign)) { + return null; + } + + return response()->json([ + 'message' => 'Кампания уже заведена в Яндексе — менять набор баннеров нельзя.', + ], 409); + } + + public function previewBanner(Request $request, int $id, int $bannerId): mixed + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + $banner = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id)->where('id', $bannerId)->firstOrFail(); + + $ext = strtolower(pathinfo($banner->path, PATHINFO_EXTENSION)); + $contentType = match ($ext) { + 'png' => 'image/png', + 'gif' => 'image/gif', + default => 'image/jpeg', + }; + + return Storage::disk('local')->response($banner->path, null, ['Content-Type' => $contentType]); + } + + public function approveBanners(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $has = AdCampaignBanner::where('tenant_id', $tenantId)->where('campaign_id', $campaign->id) + ->where('included', true)->exists(); + if (! $has) { + return response()->json(['message' => 'Отметьте хотя бы один баннер для показа.'], 422); + } + + $campaign->update(['banners_approved_at' => now()]); + + return response()->json(['approved_at' => $campaign->fresh()->banners_approved_at]); + } + + /** @return list */ + private function bannerSlots(AdCampaign $campaign): array + { + $existing = AdCampaignBanner::where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->get() + ->keyBy(fn ($b) => $b->width.'x'.$b->height); + + $slots = []; + foreach (BannerSizes::all() as [$w, $h]) { + $banner = $existing->get($w.'x'.$h); + $slots[] = $this->bannerSlot($campaign, $w, $h, $banner); + } + + return $slots; + } + + /** @return array{width:int,height:int,uploaded:bool,banner_id:?int,bytes:?int,included:bool,preview_url:?string} */ + private function bannerSlot(AdCampaign $campaign, int $w, int $h, ?AdCampaignBanner $banner): array + { + if ($banner === null) { + return [ + 'width' => $w, + 'height' => $h, + 'uploaded' => false, + 'banner_id' => null, + 'bytes' => null, + 'included' => true, + 'preview_url' => null, + ]; + } + + return [ + 'width' => $w, + 'height' => $h, + 'uploaded' => true, + 'banner_id' => (int) $banner->id, + 'bytes' => (int) $banner->bytes, + 'included' => (bool) $banner->included, + 'preview_url' => "/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}/preview", + ]; + } +} diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php new file mode 100644 index 00000000..4e78c942 --- /dev/null +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php @@ -0,0 +1,168 @@ +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]); + } + + /** + * Ответ клиента. Пустое сообщение без файла принимать бессмысленно — окно передачи + * должно что-то передавать. + * + * Что принимаем: 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); + + if ($file instanceof UploadedFile) { + $this->tellTruthAboutDocument($campaign, $message, $body); + } + + return response()->json(['id' => (int) $message->id], 201); + } + + /** + * Правда про приложенный документ — клиенту в ленту, владельцу письмом. + * + * 🔴 Замысел предполагал, что документ отвезёт робот прямо в кабинет Яндекса. **Такой + * дороги нет** — проверено двумя нарочными отказами 28.07.2026, обычной тематикой + * и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает + * только снаружи кабинета (чат поддержки, форма обратной связи). + * + * Молчать про это нельзя. Клиент, приложивший лицензию, будет ждать ответа Яндекса, + * которого не будет: файл просто ляжет на диск. Поэтому говорим прямо и зовём живого + * человека — иначе «разберёмся вручную» было бы пустым обещанием. + * + * Всё внутри под Throwable: отметка и письмо — дело второстепенное, а принятый документ + * клиента нет. Беда с почтой не должна возвращать клиенту отказ на успешно принятый файл. + */ + private function tellTruthAboutDocument(AdCampaign $campaign, AdCampaignMessage $message, string $comment): void + { + try { + app(CampaignMessageService::class)->postSystem( + $campaign, + 'Документ получен и сохранён у нас. Передать его Яндексу автоматически нельзя — ' + .'он принимает документы только от человека. Если по вашему отказу документ нужен, ' + .'мы отнесём его сами и напишем здесь.', + ); + + $to = (string) config('services.monitoring.alert_email'); + + if ($to !== '') { + Mail::to($to)->queue(new AdDocumentAttachedMail( + (string) $campaign->name, + (int) $campaign->id, + (int) $campaign->tenant_id, + (string) $message->file_name, + $comment, + )); + } + } catch (Throwable $e) { + Log::warning('Не смогли отметить приложенный документ: '.$e->getMessage(), [ + 'campaign' => $campaign->id, 'message' => $message->id, + ]); + } + } + + /** Файл отдаём только своему тенанту и только через портал — диск закрытый. */ + 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); + } +} diff --git a/app/app/Http/Controllers/Api/AdvertisingWalletController.php b/app/app/Http/Controllers/Api/AdvertisingWalletController.php new file mode 100644 index 00000000..15e50551 --- /dev/null +++ b/app/app/Http/Controllers/Api/AdvertisingWalletController.php @@ -0,0 +1,37 @@ +user()->tenant_id; + + $wallet = AdWallet::where('tenant_id', $tenantId)->first(); + $balanceRub = (string) ($wallet->balance_rub ?? '0.00'); + $frozenRub = (string) ($wallet->frozen_rub ?? '0.00'); + + return response()->json([ + 'solvent' => $gate->isSolvent($tenantId), + 'balance_rub' => $balanceRub, + 'frozen_rub' => $frozenRub, + 'free_rub' => bcsub($balanceRub, $frozenRub, 2), + ]); + } +} diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index bd168628..80c0800f 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -12,6 +12,7 @@ use App\Models\SaasTransaction; use App\Models\Tenant; use App\Models\User; use App\Repositories\PricingTierRepository; +use App\Services\Advertising\AdWalletService; use App\Services\Billing\BalanceToLeadsConverter; use App\Services\Billing\BillingTopupService; use App\Services\Billing\Gateway\PaymentGatewayManager; @@ -46,16 +47,22 @@ class BillingController extends Controller * Развилка: если флаг billing_yookassa_enabled ВКЛ — создаём платёж через * шлюз и возвращаем confirmation_url (баланс не меняется до webhook). * Если ВЫКЛ — MVP-stub мгновенного зачисления (текущее прод-поведение до Б-1). + * + * credit_target (рекламный кошелёк, Часть A): 'leads' (умолчание, старое + * поведение байт-в-байт) | 'advertising' (зачисление в ad_wallets вместо + * tenants.balance_rub). Зеркалит InvoicePaymentService. */ public function topup(Request $request): JsonResponse { $validated = $request->validate([ 'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'], + 'credit_target' => ['sometimes', 'in:leads,advertising'], ]); /** @var User $user */ $user = $request->user(); $amountRub = bcadd((string) $validated['amount_rub'], '0', 2); + $creditTarget = $validated['credit_target'] ?? SaasTransaction::CREDIT_TARGET_LEADS; // Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ). if (SystemSettings::bool('billing_yookassa_enabled')) { @@ -67,12 +74,22 @@ class BillingController extends Controller $returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return'; $result = app(OnlineTopupService::class)->start( - (int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id + (int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id, $creditTarget ); return response()->json(['confirmation_url' => $result->confirmationUrl], 201); } + // Заглушка (флаг ВЫКЛ): advertising — прямое пополнение рекламного кошелька + // (свой ledger, не balance_transactions); иначе прежнее поведение байт-в-байт. + if ($creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING) { + app(AdWalletService::class)->topup( + (int) $user->tenant_id, $amountRub, 'yookassa', 'Пополнение рекламного кошелька' + ); + + return response()->json(['ok' => true], 201); + } + // Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление. $tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id); diff --git a/app/app/Http/Controllers/Api/CreativeRobotController.php b/app/app/Http/Controllers/Api/CreativeRobotController.php new file mode 100644 index 00000000..7c584aeb --- /dev/null +++ b/app/app/Http/Controllers/Api/CreativeRobotController.php @@ -0,0 +1,267 @@ +jobs->takeNext(); + + if ($job === null) { + return response()->json(['job' => null]); + } + + // Разведка — это «сходить посмотреть», а не «отвезти картинки»: роботу нужен только + // номер объявления в кабинете. Списка файлов у неё нет и быть не может, а вызывать + // ради неё подбор баннеров — значит выдать роботу работу, которой ему не поручали. + if ($job->kind !== AdCreativeJob::KIND_UPLOAD) { + // Номер кампании в Яндексе роботу нужен: список объявлений открывается только + // по нему. Наш внутренний номер кабинету ничего не говорит. + $campaign = AdCampaign::find($job->campaign_id); + + return response()->json(['job' => [ + 'id' => $job->id, + 'campaign_id' => $job->campaign_id, + 'kind' => $job->kind, + 'yandex_ad_id' => $job->yandex_ad_id, + 'yandex_campaign_id' => $campaign?->yandex_campaign_id, + 'banners' => [], + ]]); + } + + // Ровно тот же набор, который потом сопоставляется при отчёте: включённые баннеры + // без номера креатива. Возить в кабинет то, что там уже лежит, — значит плодить + // дубли, которые вычищаются только руками. + $banners = $this->jobs->bannersToUpload($job); + + return response()->json(['job' => [ + 'id' => $job->id, + 'campaign_id' => $job->campaign_id, + 'kind' => $job->kind, + 'banners' => $banners->map(fn ($b) => [ + 'banner_id' => $b->id, + 'width' => (int) $b->width, + 'height' => (int) $b->height, + 'file_url' => url("/api/creative-robot/jobs/{$job->id}/banners/{$b->id}/file"), + ])->values()->all(), + ]]); + } + + /** + * Отдать роботу файл баннера. + * + * Отдаём ТОЛЬКО баннеры задания, номер которого робот прислал в адресе, и только пока + * это задание в работе. Иначе утёкший токен позволил бы перебором номеров вычерпать + * картинки всех клиентов. + * + * Раньше задание искалось как «какое-нибудь в работе». Пока в работе строго одно + * задание, результат совпадал, но защита держалась на внешнем условии, а не на самом + * запросе. Теперь «в работе не больше одного» обеспечивает частичный уникальный индекс + * `uq_creative_job_single_taken` в базе, а выдача файла ни на что постороннее не + * опирается — она проверяет ровно то задание, о котором спросили. + */ + public function file(int $jobId, int $bannerId): StreamedResponse + { + $job = AdCreativeJob::where('id', $jobId) + ->where('status', AdCreativeJob::STATUS_TAKEN) + ->first(); + abort_if($job === null, 404, 'Задание не в работе — файлы по нему не выдаются.'); + + $banner = AdCampaignBanner::where('id', $bannerId) + ->where('campaign_id', $job->campaign_id) + ->where('included', true) + ->firstOrFail(); + + abort_unless(Storage::disk('local')->exists($banner->path), 404, 'Файл баннера не найден.'); + + // Расширение и тип — НАСТОЯЩИЕ, из самого файла. Клиенту разрешены jpg, png и gif, + // а отдавали мы всё под именем «.jpg»: робот сохранял PNG как «картинка.jpg» и таким + // же скармливал кабинету Яндекса. Кабинет либо отвергнет файл, либо примет с + // искажением — и разбираться придётся человеку по письму «не смог загрузить». + // Имя начинается с номера баннера: он уникален, размер — нет. + $ext = strtolower(pathinfo($banner->path, PATHINFO_EXTENSION)) ?: 'jpg'; + $contentType = match ($ext) { + 'png' => 'image/png', + 'gif' => 'image/gif', + default => 'image/jpeg', + }; + + return Storage::disk('local')->download( + $banner->path, + "{$banner->id}-{$banner->width}x{$banner->height}.{$ext}", + ['Content-Type' => $contentType], + ); + } + + /** + * Принять доклад разведки: что робот прочитал на экране кабинета про отклонённое + * объявление, и снимок этого экрана. + * + * 🔑 Отдельная ручка, а не `done`, потому что у разведки другой смысл слова «готово». + * У заливки готово = креативы в кабинете, портал идёт за слепком. У разведки готово = + * робот принёс ТЕКСТ, и без текста доклада не бывает. Причина отказа — единственное, + * ради чего разведка затевалась: программный интерфейс Яндекса её не отдаёт вовсе. + * + * Текст ложится в ленту от имени `yandex` и слово в слово: портал ничего не толкует + * и не сокращает. Клиенту уходит письмо и колокольчик — этим занимается сервис ленты. + */ + public function inspection(Request $request, int $jobId): JsonResponse + { + $data = $request->validate([ + // Пустой доклад — это «ничего не выяснил», а не причина отказа. Такой доклад + // клиенту в ленту класть нельзя: он выглядит как ответ Яндекса, а им не является. + 'report' => ['required', 'string', 'min:1', 'max:20000'], + 'screenshot' => ['nullable', 'file', 'mimes:png,jpg,jpeg', 'max:5120'], + ]); + + if (trim($data['report']) === '') { + return response()->json([ + 'message' => 'Пустой доклад разведки не принимается.', + 'errors' => ['report' => ['Пустой доклад разведки не принимается.']], + ], 422); + } + + $job = AdCreativeJob::findOrFail($jobId); + + abort_if( + $job->status !== AdCreativeJob::STATUS_TAKEN, + 409, + "Задание #{$job->id} не в работе (статус «{$job->status}») — доклад по нему не принимается." + ); + + // Разошлись в том, какую работу робот делал. Принять такой доклад — значит положить + // клиенту в ленту неизвестно что от имени Яндекса. + abort_if( + $job->kind !== AdCreativeJob::KIND_INSPECT, + 409, + "Задание #{$job->id} — не разведка (вид «{$job->kind}»), доклад разведки по нему не принимается." + ); + + $campaign = AdCampaign::findOrFail($job->campaign_id); + + // Баннер ищем связью ОТ КАМПАНИИ: номер объявления — чужой, из системы Яндекса, + // и брать по нему что-либо в обход кампании нельзя. Не нашёлся — доклад всё равно + // кладём, просто без привязки к размеру блока: причина важнее привязки. + $banner = $campaign->banners() + ->where('yandex_ad_id', $job->yandex_ad_id) + ->first(); + + $file = null; + $screenshot = $request->file('screenshot'); + + if ($screenshot instanceof UploadedFile) { + // Приватный диск: снимок экрана кабинета — чужая внутренняя кухня, наружу он + // уходит только через ручку портала с проверкой тенанта. + $file = [ + 'path' => $screenshot->store("ad-messages/{$campaign->tenant_id}/{$campaign->id}", 'local'), + 'name' => mb_substr($screenshot->getClientOriginalName(), 0, 255), + 'size' => (int) $screenshot->getSize(), + 'mime' => (string) $screenshot->getMimeType(), + ]; + } + + // 🔴 Именно postFromRobot, а НЕ postFromYandex: у второго стоит защита от дублей + // по последнему сообщению, и повторный отказ с той же формулировкой после починки + // она бы съела — клиент не узнал бы, что его опять не пустили. Уникальность + // разведки обеспечена на входе: одно задание на номер объявления. + app(CampaignMessageService::class)->postFromRobot( + $campaign, + $banner === null ? null : (int) $banner->id, + $data['report'], + $file, + ); + + $job->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]); + + return response()->json(['status' => AdCreativeJob::STATUS_DONE]); + } + + /** Принять отчёт робота: готово или сбой. */ + public function done(Request $request, int $jobId): JsonResponse + { + $data = $request->validate([ + 'ok' => ['required', 'boolean'], + 'reason' => ['nullable', 'string', 'max:1024'], + ]); + + // Отчёт принимаем ТОЛЬКО по заданию, которое сейчас в работе. Номер задания робот + // присылает в адресе, и без этой проверки он брался как есть: «готово» по чужому + // ЕЩЁ НЕ выданному заданию разложило бы номера креативов чужой кампании по её + // баннерам (картинка одного клиента уехала бы в объявление другого), а «сбой» по + // уже закрытому заданию переписал бы правильный результат на failed. + $job = AdCreativeJob::findOrFail($jobId); + abort_if( + $job->status !== AdCreativeJob::STATUS_TAKEN, + 409, + "Задание #{$job->id} не в работе (статус «{$job->status}») — отчёт по нему не принимается." + ); + + if ($data['ok'] === false) { + $this->jobs->fail($job, (string) ($data['reason'] ?? 'Робот не сообщил причину.')); + + return response()->json(['status' => AdCreativeJob::STATUS_FAILED]); + } + + try { + $this->jobs->complete($job); + } catch (CreativeMatchFailedException $e) { + // Задание уже помечено сбойным внутри complete(). Роботу отвечаем 200: свою + // работу он сделал, разошёлся слепок креативов — это наша сторона, не его. + return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]); + } catch (Throwable $e) { + // Приём отчёта ходит в живой Яндекс за слепком креативов. Любая другая беда + // (API недоступен, лимит, оборвалась сеть) раньше улетала наружу: робот получал + // 500, а задание НАВСЕГДА оставалось «в работе». А пока хоть одно задание в + // работе, выдача отвечает «работы нет» ВСЕМ — очередь встаёт колом для всех + // клиентов сразу. Поэтому закрываем задание сбойным: возобновляемый запуск + // поставит новое, и работа продолжится. + Log::error('Не смогли принять отчёт робота о креативах', [ + 'job_id' => $job->id, + 'campaign_id' => $job->campaign_id, + 'error' => $e->getMessage(), + ]); + + $fresh = $job->fresh(); + if ($fresh !== null && $fresh->status === AdCreativeJob::STATUS_TAKEN) { + $this->jobs->fail($fresh, 'Портал не смог принять отчёт: '.$e->getMessage()); + } + + return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]); + } + + return response()->json(['status' => AdCreativeJob::STATUS_DONE]); + } +} diff --git a/app/app/Http/Controllers/Api/InvoiceController.php b/app/app/Http/Controllers/Api/InvoiceController.php index 1548131c..8b52a55c 100644 --- a/app/app/Http/Controllers/Api/InvoiceController.php +++ b/app/app/Http/Controllers/Api/InvoiceController.php @@ -28,13 +28,15 @@ class InvoiceController extends Controller { $validated = $request->validate([ 'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'], + 'credit_target' => ['sometimes', 'in:leads,advertising'], ]); /** @var User $user */ $user = $request->user(); $amountRub = bcadd((string) $validated['amount_rub'], '0', 2); + $creditTarget = $validated['credit_target'] ?? 'leads'; try { - $invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id); + $invoice = $this->invoices->create((int) $user->tenant_id, $amountRub, (int) $user->id, $creditTarget); } catch (RequisitesIncompleteException $e) { return response()->json(['message' => $e->getMessage()], 422); } diff --git a/app/app/Http/Controllers/Api/PaymentWebhookController.php b/app/app/Http/Controllers/Api/PaymentWebhookController.php index a9f78e3f..06d6cacf 100644 --- a/app/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/app/Http/Controllers/Api/PaymentWebhookController.php @@ -63,6 +63,10 @@ class PaymentWebhookController extends Controller // тот же путь, что и у команды billing:reconcile-payments. $verify = $this->driver->verifyPayment($this->settlement->gatewayFor($tx), $paymentId); + // Рекламный кошелёк, Часть A: settle() маршрутизирует зачисление по + // saas_transactions.credit_target — 'leads' (умолчание) идёт в баланс за лиды + // (BillingTopupService) как раньше, 'advertising' — в ad_wallets (AdWalletService). + // Карта ЮKassa теперь зачисляет рекламный кошелёк наравне со счёт-фактурой (Б-1 go-live). return response()->json(['status' => $this->settlement->settle($tx, $verify, $paymentId)], 200); } } diff --git a/app/app/Http/Middleware/CreativeRobotToken.php b/app/app/Http/Middleware/CreativeRobotToken.php new file mode 100644 index 00000000..002c7cc4 --- /dev/null +++ b/app/app/Http/Middleware/CreativeRobotToken.php @@ -0,0 +1,32 @@ +header('X-Creative-Robot-Token', ''); + + if ($expected === '' || ! hash_equals($expected, $given)) { + abort(401, 'Неверный сервис-токен робота.'); + } + + return $next($request); + } +} diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php new file mode 100644 index 00000000..4a908d5a --- /dev/null +++ b/app/app/Jobs/ChargeCampaignSpendJob.php @@ -0,0 +1,111 @@ +id}:{billable}" (billable — число + * оплачиваемых показов на момент списания, счётчик сам считает дельту от + * уже списанного charged_client_rub). + */ +class ChargeCampaignSpendJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function handle(): void + { + if (! config('services.yandex_direct.enabled')) { + return; + } + + $charger = app(CampaignImpressionCharger::class); + $gate = app(AdWalletGate::class); + $stopAll = app(AdStopAllService::class); + + $direct = new YandexDirectClient( + $this->configString('services.yandex_direct.base_url'), + $this->configString('services.yandex_direct.token'), + ); + + $rows = DB::connection('pgsql_supplier')->table('ad_campaigns') + ->where('status', AdCampaign::STATUS_RUNNING) + ->whereNotNull('yandex_campaign_id') + ->get(['id', 'tenant_id', 'yandex_campaign_id']); + + foreach ($rows as $row) { + try { + $delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id); + $tenantId = (int) $row->tenant_id; + + DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + // Замок строки обязателен. Идемпотентность списания держится на ключе + // «yandex-imp:{кампания}:{показы}», а число показов приходит из отчёта + // Директа: два прогона, начавшихся одновременно (ручной запуск поверх + // расписания, повтор упавшей задачи), получат чуть разные числа — значит + // разные ключи, и уникальный индекс по ключу дубль уже не остановит. + // Клиента списали бы дважды. Под замком прогоны выстраиваются в очередь: + // второй увидит уже обновлённый charged_client_rub и спишет только дельту. + $campaign = AdCampaign::where('id', $row->id)->lockForUpdate()->firstOrFail(); + $charger->charge($campaign, $delivered); + + if (! $gate->isSolvent($tenantId)) { + $stopAll->stopAll($tenantId); + } + }); + } catch (Throwable $e) { + Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [ + 'campaign_id' => $row->id, + 'tenant_id' => $row->tenant_id, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */ + private function configString(string $key, string $default = ''): string + { + $value = config($key, $default); + + return is_string($value) ? $value : $default; + } +} diff --git a/app/app/Jobs/SyncCampaignAudienceJob.php b/app/app/Jobs/SyncCampaignAudienceJob.php new file mode 100644 index 00000000..4d0aa7ea --- /dev/null +++ b/app/app/Jobs/SyncCampaignAudienceJob.php @@ -0,0 +1,92 @@ +handle() в тестах + * (см. разбор в задаче 9: параметры handle() не резолвятся при прямом вызове). + */ +class SyncCampaignAudienceJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function handle(): void + { + if (! config('services.yandex_direct.enabled')) { + return; + } + + $token = $this->configString('services.yandex_audience.token'); + + if ($token === '') { + return; + } + + $builder = app(CampaignAudienceBuilder::class); + $client = new YandexAudienceClient($token); + + $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [ + AdCampaign::STATUS_RUNNING, + AdCampaign::STATUS_PENDING_MODERATION, + ])->whereNotNull('yandex_segment_id')->get(); + + foreach ($campaigns as $campaign) { + $phones = $builder->build($campaign); + + if ($phones === []) { + // Пустая замена — Яндекс её отвергает; тихо пропускаем эту кампанию. + continue; + } + + try { + $client->modifyData((int) $campaign->yandex_segment_id, $phones, 'replace'); + } catch (Throwable $e) { + // Сбой одной кампании не должен валить всю ночную заливку остальных. + // Телефоны (ПДн) в текст лога не попадают — только id кампании. + Log::warning('SyncCampaignAudienceJob: '.$e->getMessage(), ['campaign' => $campaign->id]); + } + } + } + + /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */ + private function configString(string $key, string $default = ''): string + { + $value = config($key, $default); + + return is_string($value) ? $value : $default; + } +} diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php new file mode 100644 index 00000000..fe71cd99 --- /dev/null +++ b/app/app/Jobs/SyncCampaignModerationJob.php @@ -0,0 +1,218 @@ +update() (баннер и кампания) идут по BYPASSRLS, пишем строго + * по загруженным строкам (без доп. tenant-фильтра). + * + * Рубильник: services.yandex_direct.enabled — пока выключен, джоб не делает + * ни одного обращения к Яндексу. + */ +class SyncCampaignModerationJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function handle(): void + { + if (! config('services.yandex_direct.enabled')) { + return; + } + + $direct = new YandexDirectClient( + $this->configString('services.yandex_direct.base_url'), + $this->configString('services.yandex_direct.token'), + ); + + $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [ + AdCampaign::STATUS_RUNNING, + AdCampaign::STATUS_PENDING_MODERATION, + ])->whereNotNull('yandex_campaign_id')->with('banners')->get(); + + foreach ($campaigns as $campaign) { + $banners = $campaign->banners->filter(fn ($b) => $b->yandex_ad_id !== null); + + if ($banners->isEmpty()) { + continue; + } + + // Под защитой не только поход в сеть, но и запись ответа: беда на одной кампании + // не должна срывать обход остальных клиентов. Сорванный обход — это чужая реклама, + // про которую никто не узнал, что её приняли или отклонили, и не вернувшиеся деньги. + try { + $moderation = $direct->getAdsModeration( + $banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all() + ); + + foreach ($banners as $banner) { + $info = $moderation[(int) $banner->yandex_ad_id] ?? null; + if ($info === null) { + continue; + } + + // Яндекс прислал объявление, но без статуса. Пустой статус класть нельзя — + // колонка его не принимает; да и «неизвестно» это не вердикт. Оставляем + // прежний статус: баннер считается ещё не решённым и держит кампанию в ожидании. + $status = $info['status'] ?? null; + if (! is_string($status) || $status === '') { + continue; + } + + // Причина отказа у Яндекса бывает длиннее нашей колонки (модератор перечисляет + // все претензии списком). Храним сколько влезает — клиенту важно начало, + // а потеря причины целиком хуже обрезанной. + // + // 🔴 А бывает и наоборот: на отказ Яндекс отдаёт машине только + // «\nОтклонено на модерации.» — ни слова о том, что не так (проверено + // живьём 28.07.2026, см. ModerationReason). Тогда вместо отписки клиент + // получает честное «причину выясняем», а настоящую принесёт разведка. + $raw = is_string($info['reason'] ?? null) ? $info['reason'] : null; + + $reason = $status === AdCampaignBanner::MOD_REJECTED + ? ModerationReason::forRejected($raw) + : ModerationReason::normalize($raw); + + $banner->update([ + 'moderation_status' => $status, + 'moderation_reason' => $reason === null ? null : mb_substr($reason, 0, 255), + ]); + + // Пояснение модератора кладём в ленту кампании ЦЕЛИКОМ: в колонке + // баннера оно обрезано до 255 знаков ради ярлыка, а клиенту нужен + // весь текст — именно по нему он поймёт, что переделывать. + // + // Отдельный try: лента — вещь второстепенная, а статус модерации нет. + // Беда с лентой не должна стоить клиенту незаписанного вердикта и + // невозвращённых денег. + if ($reason !== null) { + try { + $messages = app(CampaignMessageService::class); + + // 🪤 Заглушку «Яндекс причину не назвал» показываем ОДИН раз — + // пока настоящей причины нет. После доклада разведки она уже + // не новость, а шаг назад: клиент, прочитавший настоящую + // причину, получил бы поверх неё «причину не назвали» и ещё + // одно письмо. Обычная защита от дублей тут не спасает — она + // смотрит на ПОСЛЕДНЕЕ сообщение, а последним лежит доклад робота. + $zaglushka = $reason === ModerationReason::PRICHINA_NEIZVESTNA; + + if (! $zaglushka || ! $messages->hasFromYandex($campaign, (int) $banner->id)) { + $messages->postFromYandex($campaign, (int) $banner->id, $reason); + } + } catch (Throwable $e) { + Log::warning('Не смогли положить пояснение Яндекса в ленту: '.$e->getMessage(), [ + 'campaign' => $campaign->id, 'banner' => $banner->id, + ]); + } + } + + // Отклонили — посылаем робота посмотреть, ЗА ЧТО. Своими словами Яндекс + // машине этого не говорит: на отказ приходит «Отклонено на модерации.» + // и всё (проверено живьём 28.07.2026). Причина висит только на экране + // кабинета, и добыть её может лишь тот, у кого есть глаза. + // + // Отдельный try по той же причине, что и у ленты: очередь заданий — вещь + // второстепенная, а вердикт модерации и возврат денег нет. Беда с очередью + // не должна стоить клиенту незаписанного отказа и невернувшихся денег. + if ($status === AdCampaignBanner::MOD_REJECTED) { + try { + app(CreativeJobService::class)->enqueueInspection($campaign, $banner); + } catch (Throwable $e) { + Log::warning('Не смогли поставить роботу разведку по отказу: '.$e->getMessage(), [ + 'campaign' => $campaign->id, 'banner' => $banner->id, + ]); + } + } + } + } catch (Throwable $e) { + // ПДн в лог не попадают — только id кампании. + Log::warning('SyncCampaignModerationJob: '.$e->getMessage(), ['campaign' => $campaign->id]); + + continue; + } + + // Перечитывать баннеры не нужно: ->update() уже положил новые значения + // в ту же модель в памяти. Баннер, про который Яндекс промолчал, остаётся + // со своим прежним статусом — то есть считается ещё не решённым и держит + // кампанию в ожидании (как и раньше при пропаже объявления в ответе). + $anyAccepted = $banners->contains( + fn ($b) => $b->moderation_status === AdCampaignBanner::MOD_ACCEPTED + ); + $anyPending = $banners->contains(fn ($b) => ! in_array( + $b->moderation_status, [AdCampaignBanner::MOD_ACCEPTED, AdCampaignBanner::MOD_REJECTED], true + )); + + // Правило спеки §6: кампания работает, если принято ХОТЯ БЫ ОДНО объявление — + // отклонённые просто не показываются. В «отклонено» с разморозкой денег уходим, + // только когда отклонены ВСЕ. Так не появляется пятый выход кампании: существующие + // четыре выхода снятия заморозки остаются как есть. + if ($anyPending) { + continue; // ждём вердикта по остальным, статус кампании не трогаем + } + + $newStatus = $anyAccepted ? AdCampaign::STATUS_RUNNING : AdCampaign::STATUS_REJECTED; + + if ($newStatus === $campaign->status) { + continue; + } + + $rejectedReason = $anyAccepted + ? $campaign->moderation_reason + : $banners->pluck('moderation_reason')->first(fn ($r) => $r !== null && $r !== ''); + + $campaign->update([ + 'status' => $newStatus, + 'moderation_reason' => $rejectedReason, + ]); + + // ВЫХОД 2 — Яндекс отклонил ВЕСЬ набор объявлений. Показов не было, деньги + // не тратились: заморозку возвращаем клиенту целиком, кампания мертва. + if ($newStatus === AdCampaign::STATUS_REJECTED) { + app(AdWalletService::class)->release( + (int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, + ); + } + } + } + + /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */ + private function configString(string $key, string $default = ''): string + { + $value = config($key, $default); + + return is_string($value) ? $value : $default; + } +} diff --git a/app/app/Listeners/PauseCampaignsOnAdStop.php b/app/app/Listeners/PauseCampaignsOnAdStop.php new file mode 100644 index 00000000..89898e3f --- /dev/null +++ b/app/app/Listeners/PauseCampaignsOnAdStop.php @@ -0,0 +1,51 @@ +where('tenant_id', $event->tenantId) + ->whereIn('status', [AdCampaign::STATUS_RUNNING, AdCampaign::STATUS_PENDING_MODERATION]) + ->get(); + + if ($campaigns->isEmpty()) { + return; + } + + $enabled = (bool) config('services.yandex_direct.enabled'); + $direct = $enabled + ? new YandexDirectClient((string) config('services.yandex_direct.base_url'), (string) config('services.yandex_direct.token')) + : null; + + foreach ($campaigns as $campaign) { + if ($direct !== null && $campaign->yandex_campaign_id !== null) { + try { + $direct->suspendCampaign((int) $campaign->yandex_campaign_id); + } catch (Throwable $e) { + Log::warning('PauseCampaignsOnAdStop suspend failed: '.$e->getMessage(), ['campaign' => $campaign->id]); + } + } + $campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]); + + // ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на + // мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту + // распорядиться пополнением (в т.ч. перезапустить рекламу). + $this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id); + } + } +} diff --git a/app/app/Mail/AdDocumentAttachedMail.php b/app/app/Mail/AdDocumentAttachedMail.php new file mode 100644 index 00000000..1409b2d7 --- /dev/null +++ b/app/app/Mail/AdDocumentAttachedMail.php @@ -0,0 +1,59 @@ +campaignName.'»', + ); + } + + public function content(): Content + { + return new Content( + view: 'mail.ad-document-attached', + with: [ + 'campaignName' => $this->campaignName, + 'campaignId' => $this->campaignId, + 'tenantId' => $this->tenantId, + 'fileName' => $this->fileName, + 'comment' => $this->comment, + ], + ); + } +} diff --git a/app/app/Mail/AdModerationMessageMail.php b/app/app/Mail/AdModerationMessageMail.php new file mode 100644 index 00000000..53f14a13 --- /dev/null +++ b/app/app/Mail/AdModerationMessageMail.php @@ -0,0 +1,44 @@ +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, + ], + ); + } +} diff --git a/app/app/Mail/AdWalletLowBalanceNotification.php b/app/app/Mail/AdWalletLowBalanceNotification.php new file mode 100644 index 00000000..8e14ce50 --- /dev/null +++ b/app/app/Mail/AdWalletLowBalanceNotification.php @@ -0,0 +1,44 @@ +tenant->contact_email], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.ad_wallet_low_balance'); + } +} diff --git a/app/app/Models/AdCampaign.php b/app/app/Models/AdCampaign.php new file mode 100644 index 00000000..10fe934b --- /dev/null +++ b/app/app/Models/AdCampaign.php @@ -0,0 +1,192 @@ + self::STATUS_DRAFT, + 'mode' => self::MODE_AUTO, + 'delivered_impressions' => 0, + ]; + + protected $fillable = [ + 'tenant_id', + 'channel', + 'name', + 'mode', + 'status', + 'audience_days', + 'snapshot_from', + 'snapshot_to', + 'run_days', + 'shows_until', + 'use_uploaded_list', + 'frequency', + 'frequency_period_days', + 'estimated_impressions', + 'paid_impressions', + 'delivered_impressions', + 'budget_rub', + 'client_cpm_rub', + 'landing_url', + 'yandex_cost_rub', + 'charged_client_rub', + 'banners_approved_at', + 'daily_budget_rub', + 'yandex_segment_id', + 'yandex_retargeting_list_id', + 'yandex_campaign_id', + 'yandex_ad_group_id', + 'yandex_creative_id', + 'yandex_ad_id', + 'moderation_reason', + 'launched_at', + 'revived_at', + ]; + + protected function casts(): array + { + return [ + 'tenant_id' => 'integer', + 'mode' => 'string', + 'audience_days' => 'integer', + 'snapshot_from' => 'date:Y-m-d', + 'snapshot_to' => 'date:Y-m-d', + 'run_days' => 'integer', + 'shows_until' => 'date:Y-m-d', + 'use_uploaded_list' => 'boolean', + 'frequency' => 'integer', + 'frequency_period_days' => 'integer', + 'estimated_impressions' => 'integer', + 'paid_impressions' => 'integer', + 'delivered_impressions' => 'integer', + 'budget_rub' => 'decimal:2', + 'client_cpm_rub' => 'decimal:2', + 'landing_url' => 'string', + 'yandex_cost_rub' => 'decimal:2', + 'charged_client_rub' => 'decimal:2', + 'banners_approved_at' => 'datetime', + 'daily_budget_rub' => 'decimal:2', + 'yandex_segment_id' => 'integer', + 'yandex_retargeting_list_id' => 'integer', + 'yandex_campaign_id' => 'integer', + 'yandex_ad_group_id' => 'integer', + 'yandex_creative_id' => 'integer', + 'yandex_ad_id' => 'integer', + 'launched_at' => 'datetime', + // Когда отклонённую кампанию вернули клиенту на починку. Держит открытым + // исключение из замка правки до следующего запуска. + 'revived_at' => 'datetime', + ]; + } + + /** + * Клиентская цена за 1000 показов (CPM) для этой кампании: своя, если задана, + * иначе — глобальный дефолт из ad_settings.client_cpm_rub (сам дефолт наценки клиенту не виден). + */ + public function effectiveCpm(): string + { + // Деньги — bcmath, без float: приводим к строке scale=2 (client_cpm_rub уже decimal:2). + if ($this->client_cpm_rub !== null) { + return bcadd((string) $this->client_cpm_rub, '0', 2); + } + + $default = DB::table('ad_settings')->value('client_cpm_rub'); + + return $default !== null ? bcadd((string) $default, '0', 2) : '120.00'; + } + + /** @return HasMany */ + public function ads(): HasMany + { + return $this->hasMany(AdCampaignAd::class, 'campaign_id'); + } + + /** Набор баннеров кампании: строка = «размер + файл + креатив + объявление». + * + * @return HasMany + */ + public function banners(): HasMany + { + return $this->hasMany(AdCampaignBanner::class, 'campaign_id'); + } + + /** @return HasMany */ + public function phones(): HasMany + { + return $this->hasMany(AdCampaignPhone::class, 'campaign_id'); + } + + /** + * Лента переписки по кампании — слова Яндекса, ответы клиента, служебные отметки. + * + * 🔴 Это **единственный допустимый путь** к сообщению, когда по нему принимается + * решение: искать сообщение сырым номером нельзя. Служебный канал робота ходит под + * ролью с кросс-тенантным доступом, где RLS не отфильтрует, и поиск по номеру отдал бы + * чужой документ. Связь привязывает выборку к кампании, а кампания — к её владельцу. + * + * @return HasMany + */ + public function messages(): HasMany + { + return $this->hasMany(AdCampaignMessage::class, 'campaign_id'); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/app/Models/AdCampaignAd.php b/app/app/Models/AdCampaignAd.php new file mode 100644 index 00000000..98f0bf3e --- /dev/null +++ b/app/app/Models/AdCampaignAd.php @@ -0,0 +1,64 @@ + 'integer', + 'campaign_id' => 'integer', + 'yandex_ad_id' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/app/Models/AdCampaignBanner.php b/app/app/Models/AdCampaignBanner.php new file mode 100644 index 00000000..e44f3ed5 --- /dev/null +++ b/app/app/Models/AdCampaignBanner.php @@ -0,0 +1,48 @@ + 'integer', + 'campaign_id' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'bytes' => 'integer', + 'included' => 'boolean', + 'yandex_creative_id' => 'integer', + 'yandex_ad_id' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/AdCampaignMessage.php b/app/app/Models/AdCampaignMessage.php new file mode 100644 index 00000000..6d9ee117 --- /dev/null +++ b/app/app/Models/AdCampaignMessage.php @@ -0,0 +1,44 @@ + 'integer', + 'campaign_id' => 'integer', + 'banner_id' => 'integer', + 'file_size' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} diff --git a/app/app/Models/AdCampaignPhone.php b/app/app/Models/AdCampaignPhone.php new file mode 100644 index 00000000..7c318d0e --- /dev/null +++ b/app/app/Models/AdCampaignPhone.php @@ -0,0 +1,49 @@ + 'integer', + 'campaign_id' => 'integer', + 'expires_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/app/Models/AdCreativeJob.php b/app/app/Models/AdCreativeJob.php new file mode 100644 index 00000000..71fdfc4b --- /dev/null +++ b/app/app/Models/AdCreativeJob.php @@ -0,0 +1,69 @@ + 'integer', + 'campaign_id' => 'integer', + 'message_id' => 'integer', + 'yandex_ad_id' => 'integer', + 'attempts' => 'integer', + 'snapshot_before' => 'array', + 'taken_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } + + /** + * Сообщение ленты, документ из которого везёт задание вида `deliver`. + * + * @return BelongsTo + */ + public function message(): BelongsTo + { + return $this->belongsTo(AdCampaignMessage::class, 'message_id'); + } +} diff --git a/app/app/Models/SaasInvoice.php b/app/app/Models/SaasInvoice.php index 170776c0..ee69a976 100644 --- a/app/app/Models/SaasInvoice.php +++ b/app/app/Models/SaasInvoice.php @@ -29,6 +29,7 @@ use Illuminate\Support\Carbon; * @property string|null $vat_amount * @property string $amount_total * @property string|null $payment_purpose + * @property string $credit_target * @property int|null $transaction_id * @property string|null $pdf_path * @property string $status @@ -56,7 +57,7 @@ class SaasInvoice extends Model protected $fillable = [ 'tenant_id', 'legal_entity_id', 'invoice_number', 'payer_type', 'payer_name', 'payer_inn', 'payer_kpp', 'payer_address', 'payer_email', - 'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose', + 'amount_net', 'vat_rate', 'vat_amount', 'amount_total', 'payment_purpose', 'credit_target', 'transaction_id', 'pdf_path', 'status', 'issued_at', 'expires_at', 'paid_at', 'cancelled_at', ]; diff --git a/app/app/Models/SaasTransaction.php b/app/app/Models/SaasTransaction.php index a177b39d..5a9e0fd5 100644 --- a/app/app/Models/SaasTransaction.php +++ b/app/app/Models/SaasTransaction.php @@ -27,11 +27,17 @@ class SaasTransaction extends Model public const TYPE_TOPUP = 'topup'; + /** Куда зачисляется онлайн-оплата: 'leads' (баланс за лиды, умолчание) | 'advertising' (рекламный кошелёк). */ + public const CREDIT_TARGET_LEADS = 'leads'; + + public const CREDIT_TARGET_ADVERTISING = 'advertising'; + protected $fillable = [ 'tenant_id', 'type', 'amount_rub', 'balance_rub_after', 'leads_credited', 'gateway_id', 'gateway_code', 'gateway_payment_id', 'gateway_idempotence_key', 'payment_method', 'legal_entity_id', 'invoice_id', 'upd_id', 'status', - 'description', 'failure_reason', 'balance_transaction_id', 'created_at', 'completed_at', + 'description', 'failure_reason', 'balance_transaction_id', 'credit_target', + 'created_at', 'completed_at', ]; protected function casts(): array diff --git a/app/app/Providers/AdvertisingEventServiceProvider.php b/app/app/Providers/AdvertisingEventServiceProvider.php new file mode 100644 index 00000000..07b3ec5f --- /dev/null +++ b/app/app/Providers/AdvertisingEventServiceProvider.php @@ -0,0 +1,18 @@ +orderBy('id') ->first(); }); + + // Инцидент 24.07.2026: гвард портала отдела продаж — ТОЛЬКО по Bearer-токену. + // Штатный Sanctum-гвард перед проверкой токена смотрит config('sanctum.guard') + // = ['web'] и, если на stateful-домене lk.liderra.ru в том же браузере открыт + // обычный кабинет (активная web-сессия), возвращает App\Models\User вместо + // SalesUser — даже не заглянув в токен. Это ломало ВЕСЬ портал (500 на всех + // маршрутах, роль слетала в «менеджера»). Свой резолвер в сессию не смотрит: + // сразу проверяет токен и пускает только SalesUser. Проверки повторяют + // Laravel\Sanctum\Guard (срок жизни, expires_at, отметка last_used_at). + // Чтение personal_access_tokens — на дефолтном соединении, которое middleware + // 'admin-db' (crm_admin_user) уже переключил ДО auth:sales — как и раньше. + Auth::viaRequest('sales-token', function (Request $request) { + $bearer = $request->bearerToken(); + if ($bearer === null || $bearer === '') { + return null; + } + + $accessToken = PersonalAccessToken::findToken($bearer); + if ($accessToken === null) { + return null; + } + + $tokenable = $accessToken->tokenable; + if (! $tokenable instanceof SalesUser) { + return null; // токен не портала продаж — этому гварду не подходит + } + + $expiration = config('sanctum.expiration'); + if (is_int($expiration) && $accessToken->created_at !== null + && $accessToken->created_at->lte(now()->subMinutes($expiration))) { + return null; + } + if ($accessToken->expires_at !== null && $accessToken->expires_at->isPast()) { + return null; + } + + $accessToken->forceFill(['last_used_at' => now()])->save(); + + return $tokenable->withAccessToken($accessToken); + }); } } diff --git a/app/app/Services/Advertising/AdImpressionPricing.php b/app/app/Services/Advertising/AdImpressionPricing.php new file mode 100644 index 00000000..6b6ced61 --- /dev/null +++ b/app/app/Services/Advertising/AdImpressionPricing.php @@ -0,0 +1,49 @@ +roundUpToKopeck($raw); + } + + /** Маржа (₽) = списано клиенту − расход Яндекса. */ + public function marginRub(string $chargedClientRub, string $yandexCostRub): string + { + return bcsub($chargedClientRub, $yandexCostRub, 2); + } + + /** Округление денежной строки ВВЕРХ до 2 знаков (копейки). */ + private function roundUpToKopeck(string $value): string + { + $kopecks = bcmul($value, '100', 6); + $floor = bcdiv($kopecks, '1', 0); + if (bccomp($kopecks, $floor, 6) > 0) { + $floor = bcadd($floor, '1', 0); + } + + return bcdiv($floor, '100', 2); + } +} diff --git a/app/app/Services/Advertising/AdStopAllService.php b/app/app/Services/Advertising/AdStopAllService.php new file mode 100644 index 00000000..7e6b55b4 --- /dev/null +++ b/app/app/Services/Advertising/AdStopAllService.php @@ -0,0 +1,38 @@ +contact_email === '') { + return; + } + + $wallet = AdWallet::where('tenant_id', $tenantId)->first(); + + Mail::queue(new AdWalletLowBalanceNotification( + $tenant, + (string) ($wallet->balance_rub ?? '0.00'), + (string) ($wallet->frozen_rub ?? '0.00'), + )); + } +} diff --git a/app/app/Services/Advertising/AdWalletGate.php b/app/app/Services/Advertising/AdWalletGate.php new file mode 100644 index 00000000..dc2d2caa --- /dev/null +++ b/app/app/Services/Advertising/AdWalletGate.php @@ -0,0 +1,27 @@ +first(); + if ($wallet === null) { + return true; + } + + return bccomp((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2) >= 0; + } +} diff --git a/app/app/Services/Advertising/AdWalletService.php b/app/app/Services/Advertising/AdWalletService.php index 57c99aab..1e3cca92 100644 --- a/app/app/Services/Advertising/AdWalletService.php +++ b/app/app/Services/Advertising/AdWalletService.php @@ -16,13 +16,36 @@ use Illuminate\Support\Facades\DB; * MONEY-код: только bcmath (scale 2), атомарно в DB::transaction, замок по * строке кошелька БЕРЁТСЯ ДО чтения баланса — порядок зеркалит * App\Services\Sms\SmsChargeService. + * + * 🔴 Каждый метод ставит СВОЙ tenant-контекст первым делом в транзакции. + * Защита по клиентам на `ad_wallet*` устроена СТРОГО: + * `USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)` + * — без контекста сравнение с NULL, и база молча отдаёт ноль строк. В вебе контекст + * ставит middleware, но из очереди (SyncCampaignModerationJob) его нет, и `release()` + * тихо не возвращал клиенту заморозку: ни ошибки, ни строки в журнале. Тесты ходят + * суперюзером и увидеть это не могут — сторож `AdWalletUnderRealRoleTest` гоняет + * деньги под боевой ролью. + * + * Полагаться на контекст вызывающего тут нельзя: `$tenantId` приходит явным доводом, + * значит и контекст — забота этого сервиса, а не каждого, кто его позовёт. */ final class AdWalletService { + /** + * Контекст клиента для защиты по строкам. SET LOCAL живёт до конца транзакции + * и PgBouncer-safe. Значение — int из сигнатуры метода, подстановка безопасна. + */ + private function tenantContext(int $tenantId): void + { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + } + /** Пополнение рекламного кошелька. Атомарно: замок по кошельку → баланс → транзакция. */ public function topup(int $tenantId, string $amountRub, ?string $channel, string $description): void { DB::transaction(function () use ($tenantId, $amountRub, $channel, $description): void { + $this->tenantContext($tenantId); + $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrCreate( ['tenant_id' => $tenantId], ['balance_rub' => '0.00', 'frozen_rub' => '0.00'], @@ -51,6 +74,8 @@ final class AdWalletService public function freeze(int $tenantId, string $channel, string $sourceType, int $sourceId, string $amountRub): void { DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId, $amountRub): void { + $this->tenantContext($tenantId); + $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail(); $already = AdWalletHold::where('tenant_id', $tenantId) @@ -71,11 +96,11 @@ final class AdWalletService $newFrozen = bcadd((string) $wallet->frozen_rub, $amountRub, 2); DB::table('ad_wallets')->where('id', $wallet->id)->update(['frozen_rub' => $newFrozen, 'updated_at' => now()]); - // Уникальный ключ hold — (tenant, channel, source_type, source_id) БЕЗ статуса, - // а release() лишь метит строку 'released'. Активной брони тут уже нет (выше - // ранний return), значит существующая строка — released: реактивируем её - // (updateOrCreate по 4-ключу), иначе create() упал бы на дубле ключа (23505) - // при пересдаче кампании / повторной заявке на имя. + // Уникальный ключ брони — (tenant, channel, source_type, source_id) БЕЗ статуса, + // а release() строку не удаляет, а метит 'released'. Активной брони тут уже нет + // (выше ранний return), значит существующая строка — released: оживляем её. + // Через create() второй заход упал бы на дубле ключа (23505) при паузе→возобновлении + // и при пересдаче отклонённой кампании. AdWalletHold::updateOrCreate( [ 'tenant_id' => $tenantId, 'channel' => $channel, @@ -97,7 +122,15 @@ final class AdWalletService public function release(int $tenantId, string $channel, string $sourceType, int $sourceId): void { DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId): void { - $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail(); + $this->tenantContext($tenantId); + + // Снятие заморозки — идемпотентная уборка на выходах кампании (завершена / + // отклонена / остановлена без средств / поставлена на паузу). Кошелька или + // активного холда может не быть вовсе (заморозки не было) — это не ошибка. + $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->first(); + if ($wallet === null) { + return; + } $hold = AdWalletHold::where('tenant_id', $tenantId) ->where('channel', $channel)->where('source_type', $sourceType) ->where('source_id', $sourceId)->where('status', AdWalletHold::STATUS_ACTIVE)->lockForUpdate()->first(); @@ -121,6 +154,8 @@ final class AdWalletService public function charge(int $tenantId, string $channel, string $relatedType, int $relatedId, string $amountRub, string $externalKey): void { DB::transaction(function () use ($tenantId, $channel, $relatedType, $relatedId, $amountRub, $externalKey): void { + $this->tenantContext($tenantId); + $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail(); $already = AdWalletTransaction::where('tenant_id', $tenantId) @@ -133,7 +168,42 @@ final class AdWalletService if (bccomp($newBalance, '0', 2) < 0) { $newBalance = '0.00'; // не уходим в минус; недобор ловит AdStopAll (Task 8) } - DB::table('ad_wallets')->where('id', $wallet->id)->update(['balance_rub' => $newBalance, 'updated_at' => now()]); + $walletUpdate = ['balance_rub' => $newBalance, 'updated_at' => now()]; + + // Заморозка ТАЕТ вместе со списанием: списанные деньги ушли с баланса и + // больше не зарезервированы. Без этого одни и те же рубли считались бы + // дважды — свободный остаток (balance − frozen) уходил бы в минус, и + // AdWalletGate::isSolvent() объявил бы клиента неплатёжеспособным сразу + // после первого суточного списания (→ AdStopAll глушит все кампании). + $hold = AdWalletHold::where('tenant_id', $tenantId) + ->where('channel', $channel)->where('source_type', $relatedType) + ->where('source_id', $relatedId)->where('status', AdWalletHold::STATUS_ACTIVE) + ->lockForUpdate()->first(); + + if ($hold !== null) { + // Больше, чем зарезервировано, не размораживаем (потолок сметы уже + // держит CampaignImpressionCharger, но арифметику страхуем здесь). + $melt = bccomp($amountRub, (string) $hold->amount_rub, 2) > 0 + ? (string) $hold->amount_rub + : $amountRub; + + $newFrozen = bcsub((string) $wallet->frozen_rub, $melt, 2); + if (bccomp($newFrozen, '0', 2) < 0) { + $newFrozen = '0.00'; + } + $walletUpdate['frozen_rub'] = $newFrozen; + + $holdRest = bcsub((string) $hold->amount_rub, $melt, 2); + AdWalletHold::where('id', $hold->id)->update([ + 'amount_rub' => $holdRest, + 'status' => bccomp($holdRest, '0', 2) === 0 + ? AdWalletHold::STATUS_RELEASED + : AdWalletHold::STATUS_ACTIVE, + 'updated_at' => now(), + ]); + } + + DB::table('ad_wallets')->where('id', $wallet->id)->update($walletUpdate); AdWalletTransaction::create([ 'tenant_id' => $tenantId, 'type' => AdWalletTransaction::TYPE_CHARGE, diff --git a/app/app/Services/Advertising/BannerGenerator.php b/app/app/Services/Advertising/BannerGenerator.php new file mode 100644 index 00000000..771fe492 --- /dev/null +++ b/app/app/Services/Advertising/BannerGenerator.php @@ -0,0 +1,65 @@ + $maxBytes && $quality >= 40); + + imagedestroy($src); + imagedestroy($dst); + + // Качество упёрлось в пол, а вес всё равно больше предела. Раньше такой файл уходил + // наружу молча — кабинет Яндекса его не примет, и узнали бы мы об этом уже роботом, + // стоящим перед окном загрузки. Отказ вслух лучше заведомо негодного файла. + if (strlen($bytes) > $maxBytes) { + throw new RuntimeException( + "Картинка не ужимается до {$maxBytes} байт для размера {$width}×{$height} — нужна другая." + ); + } + + return $bytes; + } +} diff --git a/app/app/Services/Advertising/BannerSizes.php b/app/app/Services/Advertising/BannerSizes.php new file mode 100644 index 00000000..5223c022 --- /dev/null +++ b/app/app/Services/Advertising/BannerSizes.php @@ -0,0 +1,28 @@ + */ + public const SIZES = [ + [160, 600], [240, 400], [240, 600], [300, 250], [300, 300], + [300, 500], [300, 600], [320, 480], [336, 280], [480, 320], + [728, 90], [970, 250], [320, 50], [320, 100], [1000, 120], + ]; + + /** @return list */ + public static function all(): array + { + return self::SIZES; + } +} diff --git a/app/app/Services/Advertising/BannerUploadPolicy.php b/app/app/Services/Advertising/BannerUploadPolicy.php new file mode 100644 index 00000000..6576bbd8 --- /dev/null +++ b/app/app/Services/Advertising/BannerUploadPolicy.php @@ -0,0 +1,21 @@ + */ + public const FORMATS = ['jpg', 'jpeg', 'png', 'gif']; + + public static function humanMaxSize(): string + { + return (int) round(self::MAX_BYTES / 1024).' КБ'; + } +} diff --git a/app/app/Services/Advertising/CampaignAudienceBuilder.php b/app/app/Services/Advertising/CampaignAudienceBuilder.php new file mode 100644 index 00000000..f5b77444 --- /dev/null +++ b/app/app/Services/Advertising/CampaignAudienceBuilder.php @@ -0,0 +1,78 @@ + уникальные телефоны 79XXXXXXXXX для кампании. + * + * Режим auto — скользящее окно `audience_days` от сегодня, БЕЗ своего списка + * (даже если в ad_campaign_phones есть строки — use_uploaded_list в этом режиме + * лишь индикатор для UI, на выборку не влияет). + * Режим manual — фиксированный снимок `[snapshot_from 00:00 … snapshot_to 23:59:59]` + * ПЛЮС свой список ВСЕГДА (не по флагу). Без заданных дат снимка — сделки пустые + * (не падаем), список подмешивается как обычно. + */ + public function build(AdCampaign $campaign): array + { + return DB::transaction(function () use ($campaign): array { + // RLS-контекст (сделки tenant-scoped; сервис-контекст, не middleware). + DB::statement('SET LOCAL app.current_tenant_id = '.(int) $campaign->tenant_id); + + // Явный where(tenant_id) — defense-in-depth: queue worker на prod + // (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует + // (00_create_roles.sql §5; тот же паттерн — HistoricalImportService::loadStatusOverrides). + // Без фильтра — cross-tenant утечка телефонов. + $isManual = $campaign->mode === AdCampaign::MODE_MANUAL; + + if ($isManual) { + $dealPhones = []; + if ($campaign->snapshot_from !== null && $campaign->snapshot_to !== null) { + $from = $campaign->snapshot_from->copy()->startOfDay(); + $to = $campaign->snapshot_to->copy()->endOfDay(); + + $dealPhones = DB::table('deals') + ->where('tenant_id', $campaign->tenant_id) + ->whereBetween('received_at', [$from, $to]) + ->whereNull('deleted_at') + ->pluck('phone') + ->all(); + } + } else { + $dealPhones = DB::table('deals') + ->where('tenant_id', $campaign->tenant_id) + ->where('received_at', '>=', now()->subDays((int) $campaign->audience_days)) + ->whereNull('deleted_at') + ->pluck('phone') + ->all(); + } + + $listPhones = []; + if ($isManual) { + $listPhones = DB::table('ad_campaign_phones') + ->where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->pluck('phone') + ->all(); + } + + // deals.phone уже 79…; свой список приводим к формату Яндекса на всякий. + $normalized = PhoneNormalizer::cleanContactList([...$dealPhones, ...$listPhones]); + + return array_values(array_unique($normalized)); + }); + } + + public function size(AdCampaign $campaign): int + { + return count($this->build($campaign)); + } +} diff --git a/app/app/Services/Advertising/CampaignBannerService.php b/app/app/Services/Advertising/CampaignBannerService.php new file mode 100644 index 00000000..f2ac0278 --- /dev/null +++ b/app/app/Services/Advertising/CampaignBannerService.php @@ -0,0 +1,53 @@ +tenant_id) + ->where('campaign_id', $campaign->id)->get(); + foreach ($old as $banner) { + $disk->delete($banner->path); + $banner->delete(); + } + + $count = 0; + foreach (BannerSizes::all() as [$w, $h]) { + $jpeg = $this->generator->coverJpeg($sourceBinary, $w, $h); + $path = "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x{$h}.jpg"; + $disk->put($path, $jpeg); + + AdCampaignBanner::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => $h, + 'path' => $path, + 'bytes' => strlen($jpeg), + ]); + $count++; + } + + return $count; + } +} diff --git a/app/app/Services/Advertising/CampaignEstimateService.php b/app/app/Services/Advertising/CampaignEstimateService.php new file mode 100644 index 00000000..d71959e9 --- /dev/null +++ b/app/app/Services/Advertising/CampaignEstimateService.php @@ -0,0 +1,37 @@ +value('client_cpm_rub') ?? '120.00'); + $impressions = $this->pricing->impressionsForAudience($size, $frequency); + + return [ + 'size' => max(0, $size), + 'frequency' => max(0, $frequency), + 'impressions' => $impressions, + 'cpm_rub' => $cpm, + 'cost_rub' => $this->pricing->clientCostRub($impressions, $cpm), + 'min' => self::MIN_AUDIENCE, + 'enough' => $size >= self::MIN_AUDIENCE, + ]; + } +} diff --git a/app/app/Services/Advertising/CampaignImpressionCharger.php b/app/app/Services/Advertising/CampaignImpressionCharger.php new file mode 100644 index 00000000..671b9942 --- /dev/null +++ b/app/app/Services/Advertising/CampaignImpressionCharger.php @@ -0,0 +1,99 @@ +effectiveCpm(); + + $paid = (int) ($campaign->paid_impressions ?? 0); + $billable = $paid > 0 + ? min($deliveredImpressions, $paid) + : max(0, $deliveredImpressions); + + $totalCost = $this->pricing->clientCostRub($billable, $cpm); + + $already = (string) ($campaign->charged_client_rub ?? '0.00'); + $delta = bcsub($totalCost, $already, 2); + + // Списываем ТОЛЬКО при росте накопленной стоимости и держим charged_client_rub + // монотонным (high-water mark). Если отчёт Директа вернул МЕНЬШЕ показов + // (коррекция/eventual-consistency ALL_TIME-отчёта), баланс не трогаем и базу не + // опускаем — иначе последующий рост списал бы дельту от заниженной базы поверх + // уже начисленного (двойное списание). + if (bccomp($delta, '0.00', 2) === 1) { + $this->wallet->charge( + (int) $campaign->tenant_id, + 'yandex', + 'campaign', + (int) $campaign->id, + $delta, + "yandex-imp:{$campaign->id}:{$billable}", + ); + $charged = $totalCost; + } else { + $charged = $already; + } + + // Наш расход у Яндекса (для админ-маржи, Часть 4) — считаем от той же суммы, что + // фактически начислена клиенту, наценка сверху НЕ видна клиенту нигде (модель hidden). + $margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'); + $share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ + $yandexCost = bcmul($charged, $share, 2); + + $update = [ + 'delivered_impressions' => $deliveredImpressions, + 'charged_client_rub' => $charged, + 'yandex_cost_rub' => $yandexCost, + ]; + + // У ВЫХОДА 1 два повода закрыть кампанию, и оба ведут в один и тот же переход в + // `completed` с одним вызовом release() ниже — новых мест снятия заморозки не + // появляется: + // а) смета откручена — delivered добрался до оплаченного числа показов; + // б) срок показа истёк — EndDate в Директе прошёл, показов больше не будет. + // + // Без (б) недокрученная кампания висела бы `running` вечно, а деньги клиента + // оставались бы замороженными навсегда. Для медийки по списку телефонов недокрут — + // типовой исход: аудитория ограничена размером списка, частота показов — настройкой. + $showsUntil = $campaign->shows_until; + $periodOver = $showsUntil instanceof DateTimeInterface + && now()->toDateString() > $showsUntil->format('Y-m-d'); + + if (($paid > 0 && $deliveredImpressions >= $paid) || $periodOver) { + $update['status'] = AdCampaign::STATUS_COMPLETED; + } + + $campaign->update($update); + + // ВЫХОД 1 — смета откручена либо срок показа истёк. Остаток заморозки (смета + // стоила меньше, чем заморозили при запуске, либо не докрутилась вовсе) + // возвращаем в свободные деньги: кампания больше не претендует на эти рубли. + if (($update['status'] ?? null) === AdCampaign::STATUS_COMPLETED) { + $this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id); + } + } +} diff --git a/app/app/Services/Advertising/CampaignLauncher.php b/app/app/Services/Advertising/CampaignLauncher.php new file mode 100644 index 00000000..5934ae1f --- /dev/null +++ b/app/app/Services/Advertising/CampaignLauncher.php @@ -0,0 +1,371 @@ +claim($campaign); + + try { + $this->run($campaign); + } catch (Throwable $e) { + // Захват не должен превращаться в ловушку: на любой ошибке возвращаем прежний + // статус, иначе клиент, у которого не хватило аудитории или лёг Яндекс, больше + // никогда не смог бы нажать «запустить». + // + // Возвращаем именно СТАТУС и ничего больше: номера уже созданных в Яндексе + // сущностей записаны по ходу дела и должны уцелеть — на них держится + // возобновляемый запуск. + AdCampaign::where('id', $campaign->id)->update(['status' => $previousStatus]); + $campaign->setAttribute('status', $previousStatus); + + throw $e; + } + } + + /** + * Захватить кампанию под запуск: под замком строки перевести её в промежуточный статус + * `launching`. Возвращает статус, который был до захвата (его вернём при неудаче). + * + * Зачем замок. Прежняя проверка «статус draft или queued» была обычным чтением. Два + * одновременных нажатия «запустить» проходили её оба, каждый со своего экземпляра модели + * видел `yandex_campaign_id = 0` — и оба шли по всей цепочке: два сегмента Аудиторий, два + * условия ретаргетинга, ДВЕ медийные кампании, две группы, два комплекта объявлений. + * Заморозка денег при этом одна (она идемпотентна), то есть портал списал бы за одну + * кампанию, а крутились бы две — на деньги клиента. Ответ 202 «попробуйте чуть позже» + * (запуск без готовых креативов) прямо приглашает нажать ещё раз, так что повтор — + * штатный сценарий, а не экзотика. + * + * Почему не одна большая транзакция на весь запуск: по ходу дела мы записываем номера + * созданных в Яндексе сущностей, и эти записи обязаны уцелеть при обрыве — на них держится + * возобновляемый запуск. Откат транзакции стёр бы их, и повторный заход завёл бы вторую + * кампанию-сироту. Плюс запуск — это цепочка сетевых обращений, держать всё это время + * открытую транзакцию нельзя. + * + * Брошенный захват. Если процесс убили насмерть (перезагрузка сервера, убитый воркер), + * вернуть статус некому. Поэтому захват старше пятнадцати минут считается брошенным и + * перехватывается — иначе кампания застряла бы в `launching` навсегда. Признак свежести — + * `updated_at`: живой запуск обновляет строку кампании по ходу дела, записывая номера + * созданных сущностей, поэтому он остаётся свежим сам собой. + */ + private function claim(AdCampaign $campaign): string + { + $previous = DB::transaction(function () use ($campaign): ?string { + $fresh = AdCampaign::where('id', $campaign->id)->lockForUpdate()->first(); + if ($fresh === null) { + return null; + } + + if (in_array($fresh->status, [AdCampaign::STATUS_DRAFT, AdCampaign::STATUS_QUEUED], true)) { + $previous = (string) $fresh->status; + } elseif ($this->claimAbandoned($fresh)) { + $previous = AdCampaign::STATUS_DRAFT; + } else { + return null; + } + + $fresh->update(['status' => AdCampaign::STATUS_LAUNCHING]); + + return $previous; + }); + + if ($previous === null) { + throw new RuntimeException( + 'Кампания уже запущена — повторный запуск невозможен. Запустить можно только черновик или кампанию, готовую к запуску.' + ); + } + + $campaign->setAttribute('status', AdCampaign::STATUS_LAUNCHING); + + return $previous; + } + + /** Захват брошен: помечена «запускается», но строку не трогали дольше STUCK_LAUNCH_MINUTES. */ + private function claimAbandoned(AdCampaign $campaign): bool + { + return $campaign->status === AdCampaign::STATUS_LAUNCHING + && $campaign->updated_at !== null + && $campaign->updated_at->lt(now()->subMinutes(self::STUCK_LAUNCH_MINUTES)); + } + + /** Сам запуск. Вызывается только из launch() — после успешного захвата кампании. */ + private function run(AdCampaign $campaign): void + { + + // Конструктор креативов Яндекса закрыт — креатив у каждого размера свой, номера + // проставляет робот-грузчик либо оператор руками. Запускаться без номеров нельзя: + // объявление уедет пустым. Основание — findings 2026-07-27. + $banners = AdCampaignBanner::where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->where('included', true) + ->orderBy('width')->orderBy('height') + ->get(); + + if ($banners->isEmpty()) { + throw new RuntimeException('У кампании нет ни одного включённого баннера — клиенту загрузить картинки в мастере.'); + } + + // Ноль ловим наравне с пустотой НАМЕРЕННО: это мусор, а не номер. Раньше он попадал + // сюда случайно, нестрогим сравнением; строгое сравнение «по уму» выпустило бы ноль + // дальше — в запрос к Яндексу. + $withoutCreative = $banners->first( + fn ($b) => $b->yandex_creative_id === null || (int) $b->yandex_creative_id === 0 + ); + if ($withoutCreative !== null) { + throw new RuntimeException( + "У баннера {$withoutCreative->width}×{$withoutCreative->height} нет номера креатива Яндекса — креативы ещё не загружены в кабинет." + ); + } + + $href = trim((string) ($campaign->landing_url ?? '')); + if ($href === '') { + throw new RuntimeException('У кампании не указан адрес сайта — клиенту вписать адрес в мастере кампании.'); + } + + $estImpr = (int) ($campaign->estimated_impressions ?? 0); + if ($estImpr <= 0) { + throw new RuntimeException('Не задана смета показов.'); + } + + $phones = $this->audience->build($campaign); + if (count($phones) < 100) { + throw new AudienceTooSmallException(count($phones)); // Яндекс не запустит сегмент <100 + } + + // Деньги: только bcmath, микросы — целые. + $margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'); + $share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ + $clientCpm = $campaign->effectiveCpm(); // ₽/1000, 2 знака + $yandexCpm = bcmul($clientCpm, $share, 2); // ₽/1000 для Яндекса + $averageCpmMicros = (int) bcmul($yandexCpm, '1000000', 0); + $yandexBudgetRub = bcdiv(bcmul((string) $estImpr, $yandexCpm, 6), '1000', 2); // ₽ за период + $guard = (string) config('services.yandex_direct.spend_limit_guard_multiplier'); + $spendLimitMicros = (int) bcmul(bcmul($yandexBudgetRub, $guard, 2), '1000000', 0); + $freezeClientRub = $this->pricing->clientCostRub($estImpr, $clientCpm); // клиентские ₽ + + [$startDate, $endDate] = $this->resolvePeriod($campaign); + + $freqImpr = (int) ($campaign->frequency ?? 1); + $freqPeriod = (int) ($campaign->frequency_period_days ?? 1); + + // Запуск ВОЗОБНОВЛЯЕМЫЙ: номер каждой созданной в Яндексе сущности пишется на + // кампанию СРАЗУ. Если связь оборвётся на середине (например, на третьем объявлении + // из пятнадцати), портал будет знать, что уже создано, и повторный запуск дочертит + // недостающее, а не заведёт вторую кампанию-сироту, которая начнёт крутить деньги. + + // 1) Сегмент Аудиторий. + $segmentId = (int) $campaign->yandex_segment_id; + if ($segmentId === 0) { + $segmentId = (new YandexAudienceClient((string) config('services.yandex_audience.token'))) + ->createSegment('Лидерра кампания #'.$campaign->id, $phones); + $campaign->update(['yandex_segment_id' => $segmentId]); + } + + // 2) Директ: retargeting → CPM-кампания → CPM-группа → медиа-таргет → баннер. + $direct = new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + + $retId = (int) $campaign->yandex_retargeting_list_id; + if ($retId === 0) { + $retId = $direct->addRetargetingList('Лидерра #'.$campaign->id, $segmentId); + $campaign->update(['yandex_retargeting_list_id' => $retId]); + } + + $campaignId = (int) $campaign->yandex_campaign_id; + if ($campaignId === 0) { + $campaignId = $direct->addCpmBannerCampaign( + 'Лидерра #'.$campaign->id, + $startDate, + $endDate, + $averageCpmMicros, + $spendLimitMicros, + $freqImpr, + $freqPeriod, + ); + // Дату последнего дня показа записываем ВМЕСТЕ с номером кампании: она значима + // именно потому, что её теперь держит Яндекс (EndDate). По ней суточное списание + // закрывает кампанию, не добравшую смету, и возвращает клиенту замороженный + // остаток — иначе деньги остались бы заперты навсегда. + $campaign->update(['yandex_campaign_id' => $campaignId, 'shows_until' => $endDate]); + } + + // Группа + аудитория к ней — ОДИН неделимый шаг. Номер группы пишется на кампанию + // ПОСЛЕДНИМ, только после успешного addMediaAudienceTarget: он здесь не «группа + // создана», а отметка «группа готова ЦЕЛИКОМ». Инвариант железный: есть + // yandex_ad_group_id ⇒ аудитория на этой группе точно висит. + // + // Если оборвётся между созданием группы и таргетом — номер не запишется, повторный + // запуск заведёт группу заново и повесит аудиторию на неё. В кабинете останется + // пустая группа-сирота: без аудитории и без объявлений она ничего не показывает и + // денег не тратит. Это несравнимо дешевле обратного варианта — переиспользовать + // группу без аудитории и получить кампанию, которая молча никогда не заработает. + $adGroupId = (int) $campaign->yandex_ad_group_id; + if ($adGroupId === 0) { + $adGroupId = $direct->addCpmBannerAdGroup($campaignId, 'Группа #'.$campaign->id, config('services.yandex_direct.region_ids')); + $direct->addMediaAudienceTarget($adGroupId, $retId); + $campaign->update(['yandex_ad_group_id' => $adGroupId]); + } + + // Одно объявление на каждый включённый баннер: у медийной кампании нет адаптивного + // креатива, каждый размер блока — своё объявление со своим креативом. Баннеры, у + // которых номер объявления уже есть, пропускаем — их создал прошлый заход. + $pending = $banners->filter(fn ($banner) => (int) $banner->yandex_ad_id === 0); + $this->assertCreativeSizesMatch($direct, $pending); + + foreach ($banners as $banner) { + if ((int) $banner->yandex_ad_id > 0) { + continue; + } + + $adId = $direct->addCpmBannerAd($adGroupId, (int) $banner->yandex_creative_id, $href); + $banner->update([ + 'yandex_ad_id' => $adId, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + } + + // 3) Заморозить клиентскую сумму за смету показов в кошельке (Часть A). + $this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, $freezeClientRub); + + // 4) Записать статус и ОПЛАЧЕННУЮ смету показов. Номера сущностей Яндекса уже + // записаны выше по ходу создания; здесь — только то, что означает «запуск + // доведён до конца». paid_impressions = смета, за которую заморозили деньги — + // это потолок биллинга: CampaignImpressionCharger ограничивает списание этим + // числом и по достижении помечает кампанию COMPLETED. Без него списание шло бы + // без потолка (до SpendLimit Яндекса ×guard) и кампания не завершалась бы никогда. + // + // yandex_ad_id на КАМПАНИИ не пишем: объявлений теперь набор, их номера живут на + // ad_campaign_banners.yandex_ad_id. Колонка кампании остаётся в базе как + // аварийный ручной путь; продуктового кода, который её читает, нет. + // + // client_cpm_rub пишем ОБЯЗАТЕЛЬНО, даже если кампания завелась без своей цены. + // Пока поле пустое, effectiveCpm() читает глобальную цену из настроек рекламы — + // и стоило админу её поменять, как суточное списание шло уже по НОВОЙ, выше + // замороженной. Клиент платил бы больше, чем ему обещали при запуске. + // revived_at гасим здесь же: отметка «отдана клиенту на починку» держит открытым + // исключение из замка правки, и жить ей ровно до нового запуска. Объявления + // созданы — дальше правка снова расходилась бы с кабинетом Яндекса. + $campaign->update([ + 'paid_impressions' => $estImpr, + 'client_cpm_rub' => $clientCpm, + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'launched_at' => now(), + 'revived_at' => null, + ]); + } + + /** + * Сверяет настоящий размер каждого креатива с размером баннера ПЕРЕД созданием объявлений. + * + * Номер креатива попадает на баннер двумя путями: от робота-грузчика (опознание по разнице + * слепков) и руками оператора. Ошибка на любом из них — объявление 300×250, в котором + * крутится картинка 728×90: клиент платит за показы битого или вовсе чужого баннера, + * а увидеть это можно только глазами в кабинете. Одна проверка закрывает весь класс. + * + * Один запрос на весь набор, только по баннерам, которым объявление ещё предстоит создать: + * у остальных объявление уже живёт в кабинете, и сверять там нечего. + * + * @param Collection $banners + */ + private function assertCreativeSizesMatch(YandexDirectClient $direct, $banners): void + { + if ($banners->isEmpty()) { + return; + } + + $sizes = $direct->getCreativeSizes($banners->map(fn ($b) => (int) $b->yandex_creative_id)->all()); + + foreach ($banners as $banner) { + $creativeId = (int) $banner->yandex_creative_id; + $actual = $sizes[$creativeId] ?? null; + + if ($actual === null) { + throw new RuntimeException( + "Креатива №{$creativeId} нет в рекламном кабинете — объявление {$banner->width}×{$banner->height} " + .'по нему не создать. Загрузите картинку заново.' + ); + } + + if ($actual[0] !== (int) $banner->width || $actual[1] !== (int) $banner->height) { + throw new RuntimeException( + "У баннера {$banner->width}×{$banner->height} стоит креатив №{$creativeId}, " + ."а его настоящий размер — {$actual[0]}×{$actual[1]}. Размер не совпадает, запуск остановлен." + ); + } + } + } + + /** + * Период показа: manual + заданный run_days — от сегодня на run_days дней; + * иначе (auto или без run_days) — от сегодня на run_days (если задан) или 30 дней. + * + * @return array{0: string, 1: string} [startDate, endDate] в формате YYYY-MM-DD. + */ + private function resolvePeriod(AdCampaign $campaign): array + { + $startDate = now()->toDateString(); + $runDays = (int) ($campaign->run_days ?: 30); + + // Возобновляемый запуск: если дата конца уже записана, кампания в Директе заведена + // прошлым заходом ровно с ней — переиспользуем, иначе портал считал бы срок показа + // по одной дате, а Яндекс прекращал показы по другой. + $stored = $campaign->shows_until; + $endDate = $stored instanceof DateTimeInterface + ? $stored->format('Y-m-d') + : now()->addDays($runDays)->toDateString(); + + return [$startDate, $endDate]; + } +} diff --git a/app/app/Services/Advertising/CampaignMessageService.php b/app/app/Services/Advertising/CampaignMessageService.php new file mode 100644 index 00000000..5c4a2e6d --- /dev/null +++ b/app/app/Services/Advertising/CampaignMessageService.php @@ -0,0 +1,180 @@ +fromYandexQuery($campaign, $bannerId) + ->orderByDesc('id') + ->first(); + + if ($last !== null && $last->body === $body) { + return null; + } + + return $this->write($campaign, $bannerId, $body, null); + } + + /** + * Доклад робота-разведчика: что он прочитал на экране кабинета, плюс снимок этого окна. + * Автор тот же — `yandex`: это слова Яндекса, откуда бы мы их ни взяли. + * + * 🔴 Защиты от дублей здесь НЕТ намеренно. Разведка ставится ровно один раз на номер + * объявления (`CreativeJobService::enqueueInspection`) — уникальность уже обеспечена + * там, на входе. Повесь мы дедуп ещё и здесь, повторный отказ с той же формулировкой + * после починки был бы съеден, и клиент не узнал бы, что его опять не пустили. + * + * @param array{path: string, name: string, size: int, mime: string}|null $file снимок экрана + */ + public function postFromRobot( + AdCampaign $campaign, + ?int $bannerId, + string $body, + ?array $file = null, + ): ?AdCampaignMessage { + $body = trim($body); + + return $body === '' ? null : $this->write($campaign, $bannerId, $body, $file); + } + + /** + * Есть ли по этому баннеру хоть одно слово от Яндекса. + * + * Нужно джобу модерации: заглушку «причину выясняем» имеет смысл показать ОДИН раз, + * пока настоящей причины нет. После доклада разведки она уже не новость, а шаг назад — + * клиент, прочитавший настоящую причину, получил бы поверх неё «причину не назвали». + */ + public function hasFromYandex(AdCampaign $campaign, ?int $bannerId): bool + { + return $this->fromYandexQuery($campaign, $bannerId)->exists(); + } + + /** @return Builder */ + private function fromYandexQuery(AdCampaign $campaign, ?int $bannerId) + { + return AdCampaignMessage::on($campaign->getConnectionName()) + ->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)); + } + + /** Запись сообщения от имени Яндекса + уведомление клиента. Общее для обоих входов. */ + private function write(AdCampaign $campaign, ?int $bannerId, string $body, ?array $file): AdCampaignMessage + { + $message = new AdCampaignMessage([ + 'tenant_id' => (int) $campaign->tenant_id, + 'campaign_id' => (int) $campaign->id, + 'banner_id' => $bannerId, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, + 'body' => $body, + 'file_path' => $file['path'] ?? null, + 'file_name' => $file['name'] ?? null, + 'file_size' => $file['size'] ?? null, + 'file_mime' => $file['mime'] ?? null, + ]); + $message->setConnection($campaign->getConnectionName()); + $message->save(); + + $this->notify($campaign, $body); + + return $message; + } + + /** Служебная отметка портала: расписка, а не новость — клиента ею не дёргаем. */ + 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, + ]); + } + } +} diff --git a/app/app/Services/Advertising/CampaignReviveService.php b/app/app/Services/Advertising/CampaignReviveService.php new file mode 100644 index 00000000..8b08693e --- /dev/null +++ b/app/app/Services/Advertising/CampaignReviveService.php @@ -0,0 +1,79 @@ +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')->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, + ]); + } + + // Номера кампании, группы и сегмента НЕ трогаем — на них держится + // возобновляемый запуск. Стереть их значит завести в Яндексе второй + // комплект всего и заплатить дважды. + // revived_at — отметка «отдана клиенту на починку». Она, а не статус, держит + // открытым исключение из замка правки: статус тут же становится черновиком, + // и исключение по статусу погасло бы в эту же секунду. + $campaign->update([ + 'status' => AdCampaign::STATUS_DRAFT, + 'moderation_reason' => null, + 'revived_at' => now(), + ]); + }); + } +} diff --git a/app/app/Services/Advertising/CreativeIdMatcher.php b/app/app/Services/Advertising/CreativeIdMatcher.php new file mode 100644 index 00000000..410d4678 --- /dev/null +++ b/app/app/Services/Advertising/CreativeIdMatcher.php @@ -0,0 +1,69 @@ + $before слепок до загрузки: номер → [ш, в] + * @param array $after слепок после загрузки + * @param list $expectedSizes размеры, которые робот должен был залить + * @return array «300x250» → номер креатива + * + * @throws CreativeMatchFailedException + */ + public function match(array $before, array $after, array $expectedSizes): array + { + // Пустой список ожидаемых размеров — не «всё сошлось», а тихий ноль: цикл ниже + // просто не выполнится и вернёт пустоту как успех. Задание пометится «готово» + // с нулём проставленных номеров, запуск снова увидит баннеры без креативов и + // поставит новое задание — робот будет заливать те же файлы по кругу, оставляя + // каждый раз пачку мусорных креативов в живом кабинете. Ловим это громко. + if ($expectedSizes === []) { + throw new CreativeMatchFailedException( + 'Опознавать нечего: список ожидаемых размеров пуст. Так бывает, если баннеры кампании не видны ' + .'служебной роли — проверьте, что после выката перезапущен db/03_service_bypass_policies.sql.' + ); + } + + $newIds = array_diff_key($after, $before); + + $bySize = []; + foreach ($newIds as $id => [$w, $h]) { + $bySize[$w.'x'.$h][] = (int) $id; + } + + $out = []; + foreach ($expectedSizes as [$w, $h]) { + $key = $w.'x'.$h; + $found = $bySize[$key] ?? []; + + if (count($found) === 0) { + throw new CreativeMatchFailedException( + "После загрузки не появился креатив размера {$key}." + ); + } + if (count($found) > 1) { + throw new CreativeMatchFailedException( + "После загрузки появилось несколько креативов размера {$key} — какой чей, не определить." + ); + } + + $out[$key] = $found[0]; + } + + return $out; + } +} diff --git a/app/app/Services/Advertising/CreativeJobService.php b/app/app/Services/Advertising/CreativeJobService.php new file mode 100644 index 00000000..18f5f83b --- /dev/null +++ b/app/app/Services/Advertising/CreativeJobService.php @@ -0,0 +1,384 @@ +id) + ->where('kind', AdCreativeJob::KIND_UPLOAD) + ->whereIn('status', [AdCreativeJob::STATUS_QUEUED, AdCreativeJob::STATUS_TAKEN]) + ->first(); + + if ($pending !== null) { + return $pending; + } + + return AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'kind' => AdCreativeJob::KIND_UPLOAD, + 'status' => AdCreativeJob::STATUS_QUEUED, + ]); + } + + /** + * Ставит задание «сходить посмотреть, за что Яндекс отклонил объявление». + * + * 🔑 Почему без разведки никак: программный интерфейс Яндекса причину отказа НЕ отдаёт. + * На отклонённое объявление `ads.get` возвращает «Отклонено на модерации.» и всё — + * проверено живьём боевым ключом 28.07.2026. Настоящая причина висит только на экране + * кабинета, и увидеть её может лишь тот, у кого есть глаза. + * + * 🔴 Ключ дедупа — номер объявления, а не кампания. Опрос модерации бежит по расписанию, + * а отказ никуда не девается: объявление остаётся отклонённым, пока клиент его не починит. + * Дедуп «по кампании среди незавершённых» не спас бы — первая разведка закрывается за + * минуту, следующий обход поставил бы вторую, и робот ходил бы в кабинет по кругу, а + * клиент получал бы один и тот же доклад снова и снова. Поэтому смотрим ВСЕ задания + * по этому номеру, включая закрытые. + * + * После кнопки «Исправить» объявление заводится в кабинете заново и получает новый + * номер — новый отказ честно получит свою разведку. + * + * @return AdCreativeJob|null null, если у баннера нет номера объявления — смотреть не на что + */ + public function enqueueInspection(AdCampaign $campaign, AdCampaignBanner $banner): ?AdCreativeJob + { + $adId = $banner->yandex_ad_id; + + // Объявления в кабинете нет — робота гонять незачем. Такое бывает у баннера, который + // до кабинета не доехал: заливка сорвалась, номер не проставился. + if (blank($adId)) { + return null; + } + + $tenantId = (int) $campaign->tenant_id; + + // 🔴 Свой tenant-контекст обязателен. Ставит разведку джоб опроса модерации: он бежит + // в очереди под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном соединении и БЕЗ + // tenant-контекста. Без этой строки на бою поиск дубля вернул бы ноль ВСЕГДА + // (политика при пустом контексте даёт tenant_id = NULL), а запись упала бы + // «new row violates row-level security policy» — молча, в Log::warning, при зелёных + // тестах: тесты ходят суперпользователем, которому права не проверяются. + // + // Соседний путь того же обхода — CampaignMessageService — обходит грабку иначе, + // соединением кампании (`pgsql_supplier`). Здесь так нельзя: у `crm_supplier_worker` + // прав на `ad_creative_jobs` нет вовсе, и это осознанно (журнал схемы v9.06). + // Берём рецепт ChargeCampaignSpendJob: своя транзакция + SET LOCAL. + return DB::transaction(function () use ($campaign, $tenantId, $adId) { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + $existing = AdCreativeJob::where('campaign_id', $campaign->id) + ->where('kind', AdCreativeJob::KIND_INSPECT) + ->where('yandex_ad_id', (int) $adId) + ->first(); + + if ($existing !== null) { + return $existing; + } + + return AdCreativeJob::create([ + 'tenant_id' => $tenantId, + 'campaign_id' => $campaign->id, + 'kind' => AdCreativeJob::KIND_INSPECT, + 'yandex_ad_id' => (int) $adId, + 'status' => AdCreativeJob::STATUS_QUEUED, + ]); + }); + } + + /** + * Ставит задание «отвезти документ клиента в кабинет». + * + * 🔴 **Второй рубеж защиты документа, и главный из двух.** Сообщение берётся ТОЛЬКО + * связью от кампании — сырой номер в выборку не попадает никогда. Чужой документ здесь + * просто не находится: связь привязана к кампании, кампания — к её владельцу. + * + * Первый рубеж — составные ключи базы (журнал схемы v9.13 и v9.15): они отказывают уже + * на записи. Но полагаться на них как на рабочий путь нельзя — исключение приходит + * поздно, текстом PostgreSQL, посреди чужой транзакции. Ключи стоят на случай, если + * кто-то однажды напишет запись мимо этого метода. + * + * Зачем вообще два рубежа: служебный канал робота ходит под ролью с кросс-тенантным + * доступом, где RLS не отфильтрует. Ошибка здесь — не сбой, а увезённый в чужую + * модерацию документ клиента, то есть утечка персональных данных без единой ошибки + * в журнале. + * + * @throws ModelNotFoundException документа нет у этой кампании + * @throws RuntimeException в сообщении нет вложения — везти нечего + */ + public function enqueueDelivery(AdCampaign $campaign, int $messageId): AdCreativeJob + { + $message = $campaign->messages()->findOrFail($messageId); + + // Везти нечего — задание не ставим. Иначе робот доедет до кабинета, не найдёт файла + // и закроет задание сбоем, а клиент увидит в ленте «не смогли отправить» на ровном месте. + if (blank($message->file_path)) { + throw new RuntimeException( + "В сообщении #{$message->id} нет вложения — отвозить в Яндекс нечего." + ); + } + + // Клиент нажал дважды — второго задания на тот же документ не заводим. + $pending = AdCreativeJob::where('campaign_id', $campaign->id) + ->where('kind', AdCreativeJob::KIND_DELIVER) + ->where('message_id', $message->id) + ->whereIn('status', [AdCreativeJob::STATUS_QUEUED, AdCreativeJob::STATUS_TAKEN]) + ->first(); + + if ($pending !== null) { + return $pending; + } + + return AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'kind' => AdCreativeJob::KIND_DELIVER, + 'message_id' => $message->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + ]); + } + + /** Выдаёт роботу одно задание. Null, если очередь пуста или одно уже в работе. */ + public function takeNext(): ?AdCreativeJob + { + return DB::transaction(function () { + // Замок на всю выдачу — ПЕРВОЙ строкой транзакции. Без него проверка «в работе + // никого» ничего не гарантирует: она не блокирует строку, и две одновременные + // выдачи обе её проходят. Первая берёт задание #1, вторая упирается в замок + // строки #1, после коммита перепроверяет условие, строка #1 уже не подходит — + // и вторая уносит задание #2. В работе оказываются два, слепки креативов + // перемешиваются, номер уезжает к чужому баннеру молча. + // + // Замок именно advisory (а не по строке): защищать надо саму операцию выдачи, + // строк с нужным статусом в этот момент может не быть вовсе. Снимается при + // коммите/откате транзакции автоматически. + DB::select("SELECT pg_advisory_xact_lock(hashtext('ad_creative_jobs_take'))"); + + $inFlight = AdCreativeJob::where('status', AdCreativeJob::STATUS_TAKEN)->exists(); + if ($inFlight) { + return null; + } + + $job = AdCreativeJob::where('status', AdCreativeJob::STATUS_QUEUED) + ->orderBy('id')->lockForUpdate()->first(); + + if ($job === null) { + return null; + } + + // Рубильник держит и саму выдачу, а не только слепок. Раньше проверка сидела + // внутри построения клиента Директа — то есть срабатывала только там, где мы + // ходим за слепком. Разведке слепок не нужен, и без этой строки при выключенном + // рубильнике робот получил бы задание и пошёл бы в живой кабинет Яндекса. + if (! config('services.yandex_direct.enabled')) { + throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).'); + } + + // Слепок нужен ТОЛЬКО заливке: по разнице «до/после» опознаются номера + // созданных креативов. Разведка ничего не создаёт, опознавать нечего — лишний + // поход в Яндекс внутри открытой транзакции ей только во вред. + if ($job->kind !== AdCreativeJob::KIND_UPLOAD) { + $job->update([ + 'status' => AdCreativeJob::STATUS_TAKEN, + 'attempts' => $job->attempts + 1, + 'taken_at' => now(), + ]); + + return $job->fresh(); + } + + // Слепок «до» снимаем ЗДЕСЬ и в этой же транзакции. Здесь — потому что он должен + // отражать кабинет непосредственно перед заливкой: всё, что появится после этой + // секунды, и есть работа робота. В этой же транзакции — чтобы неудача снятия + // откатила и захват: Яндекс лёг на минуту, задание осталось в очереди, робот + // придёт снова. Иначе задание ушло бы «в работу» с пустым слепком, а опознавать + // потом было бы не от чего. + // + // Да, это сетевой вызов внутри открытой транзакции — обычно так не делают. Здесь + // осознанно: робот один, за заданием он приходит раз в минуту, а цена ошибки + // (перепутанные слепки, чужой креатив в объявлении клиента) несоизмеримо выше + // короткой занятой транзакции. + $job->update([ + 'status' => AdCreativeJob::STATUS_TAKEN, + 'attempts' => $job->attempts + 1, + 'taken_at' => now(), + 'snapshot_before' => $this->client()->listImageCreativeIds(), + ]); + + return $job->fresh(); + }); + } + + /** + * Принимает отчёт робота «готово»: берёт слепок «после», сопоставляет и проставляет + * номера креативов баннерам. При несовпадении не трогает ни один баннер. + * + * @throws CreativeMatchFailedException + */ + public function complete(AdCreativeJob $job): void + { + $this->assertTaken($job); + + $banners = $this->bannersToUpload($job); + + // Проверяем ДО похода в Яндекс. Ноль баннеров — это не «нечего делать, всё хорошо», + // а признак, что мы их не видим: например, служебной роли не выдан кросс-тенантный + // доступ (`db/03_service_bypass_policies.sql` не перезапущен после выката). Пойди мы + // дальше — задание закрылось бы «готово» с нулём проставленных номеров, а запуск + // поставил бы новое задание, и робот заливал бы те же файлы по кругу. + if ($banners->isEmpty()) { + $this->markFailed($job, $e = new CreativeMatchFailedException( + "У кампании #{$job->campaign_id} не видно ни одного включённого баннера без креатива — опознавать нечего. " + .'Проверьте, что после выката перезапущен db/03_service_bypass_policies.sql.' + )); + throw $e; + } + + $expected = $banners->map(fn ($b) => [(int) $b->width, (int) $b->height])->all(); + $before = $this->normalizeSnapshot($job->snapshot_before ?? []); + $after = $this->client()->listImageCreativeIds(); + + try { + $matched = $this->matcher->match($before, $after, $expected); + } catch (CreativeMatchFailedException $e) { + $this->markFailed($job, $e); + throw $e; + } + + DB::transaction(function () use ($banners, $matched, $job) { + foreach ($banners as $banner) { + $banner->update(['yandex_creative_id' => $matched[$banner->width.'x'.$banner->height]]); + } + $job->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]); + }); + } + + /** + * Набор баннеров задания: включённые в показ и БЕЗ номера креатива. Один и тот же список + * и выдаётся роботу, и сопоставляется при отчёте — иначе стороны разойдутся. + * + * Почему только без номера. Баннер, чей креатив уже лежит в кабинете, возить незачем: + * повторная заливка оставляет там второй такой же креатив навсегда, вычистить его можно + * только руками. Хуже другое — при отчёте такой баннер попал бы в список ожидаемых + * размеров, а нового креатива его размера в разнице слепков нет; опознание падало бы + * «не появился креатив размера X» на безупречной работе робота. Дальше задание ставится + * заново, слепок снимается свежий — и так по кругу, каждый круг плодит дубли в кабинете. + * + * @return Collection + */ + public function bannersToUpload(AdCreativeJob $job) + { + return AdCampaignBanner::where('campaign_id', $job->campaign_id) + ->where('included', true) + ->whereNull('yandex_creative_id') + ->orderBy('width')->orderBy('height') + ->get(); + } + + /** Помечает задание сбойным по причине от робота. */ + public function fail(AdCreativeJob $job, string $reason): void + { + $this->assertTaken($job); + + $job->update([ + 'status' => AdCreativeJob::STATUS_FAILED, + 'failure_reason' => mb_substr($reason, 0, 1024), + 'finished_at' => now(), + ]); + } + + /** Закрыть задание сбойным с причиной несостоявшегося опознания. */ + private function markFailed(AdCreativeJob $job, CreativeMatchFailedException $e): void + { + $job->update([ + 'status' => AdCreativeJob::STATUS_FAILED, + 'failure_reason' => mb_substr($e->getMessage(), 0, 1024), + 'finished_at' => now(), + ]); + } + + /** + * Отчитаться можно только по заданию в работе. Проверка продублирована здесь, а не + * оставлена одному контроллеру: сервис зовут не только из него, а закрытое задание, + * переписанное вторым отчётом, ломает уже проставленные номера креативов. + */ + private function assertTaken(AdCreativeJob $job): void + { + if ($job->status !== AdCreativeJob::STATUS_TAKEN) { + throw new RuntimeException( + "Задание #{$job->id} не в работе (статус «{$job->status}») — отчёт по нему не принимается." + ); + } + } + + /** + * JSON возвращает ключи строками — приводим обратно к int, чтобы array_diff_key + * в CreativeIdMatcher сравнивал одинаковые типы ключей. + * + * @param array $snapshot + * @return array + */ + private function normalizeSnapshot(array $snapshot): array + { + $out = []; + foreach ($snapshot as $id => $size) { + $out[(int) $id] = [(int) $size[0], (int) $size[1]]; + } + + return $out; + } + + private function client(): YandexDirectClient + { + // Рубильник Директа держит и служебный канал робота. Раньше клиент строился + // безусловно: выдача задания и приём отчёта ходили в живой кабинет мимо рубильника — + // ровно то, от чего он и защищает. Проверка та же, что в CampaignLauncher. + if (! config('services.yandex_direct.enabled')) { + throw new RuntimeException('Яндекс.Директ выключен (рубильник yandex_direct.enabled).'); + } + + return new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + } +} diff --git a/app/app/Services/Advertising/CreativeValidator.php b/app/app/Services/Advertising/CreativeValidator.php new file mode 100644 index 00000000..9e3c8f5f --- /dev/null +++ b/app/app/Services/Advertising/CreativeValidator.php @@ -0,0 +1,173 @@ + ключ поля ('title'|'title2'|'text') → ошибка по-русски; пусто = ок + */ + public function validateText(string $title, string $text, ?string $title2 = null): array + { + $errors = []; + + $titleError = $this->validateTitle($title); + if ($titleError !== null) { + $errors['title'] = $titleError; + } + + if ($title2 !== null) { + $title2Error = $this->validateTitle2($title2); + if ($title2Error !== null) { + $errors['title2'] = $title2Error; + } + } + + $textError = $this->validateBodyText($text); + if ($textError !== null) { + $errors['text'] = $textError; + } + + return $errors; + } + + private function validateTitle(string $title): ?string + { + if (mb_strlen($title) > self::TITLE_MAX_LENGTH) { + return sprintf('Заголовок длиннее %d символов', self::TITLE_MAX_LENGTH); + } + + foreach ($this->words($title) as $word) { + if (mb_strlen($word) > self::TITLE_MAX_WORD_LENGTH) { + return sprintf('В заголовке есть слово длиннее %d символов', self::TITLE_MAX_WORD_LENGTH); + } + } + + return null; + } + + /** + * Лимит title2: 30 обычных символов + до 15 «узких» сверху (узкие не входят в счёт обычных). + * Считаем отдельно узкие и не-узкие символы, а не общий счётчик — так граничные + * случаи (много узких, мало обычных) считаются правильно по духу норматива Яндекса. + */ + private function validateTitle2(string $title2): ?string + { + [$normalCount, $narrowCount] = $this->countNormalAndNarrow($title2); + + if ($normalCount > self::TITLE2_MAX_NORMAL || $narrowCount > self::TITLE2_MAX_NARROW) { + return sprintf( + 'Второй заголовок длиннее нормы (%d обычных символов + до %d узких)', + self::TITLE2_MAX_NORMAL, + self::TITLE2_MAX_NARROW + ); + } + + return null; + } + + private function validateBodyText(string $text): ?string + { + [$normalCount, $narrowCount] = $this->countNormalAndNarrow($text); + + if ($normalCount > self::TEXT_MAX_NORMAL || $narrowCount > self::TEXT_MAX_NARROW) { + return sprintf( + 'Текст длиннее нормы (%d обычных символов + до %d узких)', + self::TEXT_MAX_NORMAL, + self::TEXT_MAX_NARROW + ); + } + + foreach ($this->words($text) as $word) { + if (mb_strlen($word) > self::TEXT_MAX_WORD_LENGTH) { + return sprintf('В тексте есть слово длиннее %d символов', self::TEXT_MAX_WORD_LENGTH); + } + } + + return null; + } + + /** + * @return array{0: int, 1: int} [количество обычных символов, количество узких символов] + */ + private function countNormalAndNarrow(string $value): array + { + $normalCount = 0; + $narrowCount = 0; + + foreach (mb_str_split($value) as $char) { + if (in_array($char, self::NARROW_CHARS, true)) { + $narrowCount++; + } else { + $normalCount++; + } + } + + return [$normalCount, $narrowCount]; + } + + /** @return list */ + private function words(string $value): array + { + $parts = preg_split('/\s+/u', trim($value)) ?: []; + + return array_values(array_filter($parts, static fn (string $w): bool => $w !== '')); + } + + /** + * Проверка изображения-креатива (обычная картинка, не «широкая»). + * + * TODO(verify): точные пределы размера/пропорций/веса — сверить с AdImage + * object page Яндекса перед боем. Здесь — минимальный барьер 450×450, + * допустимые mime и вес ≤10 МБ; строгая пропорция сторон не проверяется. + * + * @return array ключ 'image' → ошибка по-русски; пусто = ок + */ + public function validateImage(int $width, int $height, string $mime, int $bytes): array + { + if (! in_array($mime, self::IMAGE_ALLOWED_MIME, true)) { + return ['image' => sprintf('Недопустимый формат картинки: %s', $mime)]; + } + + if ($bytes > self::IMAGE_MAX_BYTES) { + return ['image' => sprintf('Картинка тяжелее %d МБ', (int) (self::IMAGE_MAX_BYTES / 1024 / 1024))]; + } + + if ($width < self::IMAGE_MIN_SIDE || $height < self::IMAGE_MIN_SIDE) { + return ['image' => sprintf('Картинка меньше минимума %d×%d px', self::IMAGE_MIN_SIDE, self::IMAGE_MIN_SIDE)]; + } + + return []; + } +} diff --git a/app/app/Services/Advertising/ModerationReason.php b/app/app/Services/Advertising/ModerationReason.php new file mode 100644 index 00000000..7f9d15d7 --- /dev/null +++ b/app/app/Services/Advertising/ModerationReason.php @@ -0,0 +1,71 @@ +call('v5/retargetinglists', 'add', [ + 'RetargetingLists' => [[ + 'Name' => mb_substr($name, 0, 250), + 'Type' => 'AUDIENCE', + 'Rules' => [[ + 'Operator' => 'ALL', + 'Arguments' => [['ExternalId' => $segmentExternalId]], + ]], + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** AdImages.upload (json/v501): base64 → AdImageHash. */ + public function uploadAdImage(string $name, string $base64Data): string + { + $res = $this->call('v501/adimages', 'add', [ + 'AdImages' => [['Name' => mb_substr($name, 0, 255), 'ImageData' => $base64Data]], + ]); + + return (string) $res['result']['AddResults'][0]['AdImageHash']; + } + + /** Ads.get: статусы модерации по id. Возвращает [adId => ['status'=>..,'state'=>..,'reason'=>..]]. + * + * @param array $adIds + * @return array> + */ + public function getAdsModeration(array $adIds): array + { + $res = $this->call('v5/ads', 'get', [ + 'SelectionCriteria' => ['Ids' => array_values($adIds)], + 'FieldNames' => ['Id', 'Status', 'State', 'StatusClarification'], + ]); + + $out = []; + foreach ($res['result']['Ads'] ?? [] as $ad) { + $out[(int) $ad['Id']] = [ + 'status' => $ad['Status'] ?? null, + 'state' => $ad['State'] ?? null, + 'reason' => $ad['StatusClarification'] ?? null, + ]; + } + + return $out; + } + + /** + * Campaigns.add: CPM_BANNER_CAMPAIGN (медийная «за показы»), стратегия CP_MAXIMUM_IMPRESSIONS + * (макс. показов в рамках бюджета), Search=SERVING_OFF. Суммы — микросы (₽ × 1e6). + */ + public function addCpmBannerCampaign( + string $name, + string $startDate, + string $endDate, + int $averageCpmMicros, + int $spendLimitMicros, + int $frequencyImpressions, + int $frequencyPeriodDays, + string $autoContinue = 'NO', + ): int { + $res = $this->call('v5/campaigns', 'add', [ + 'Campaigns' => [[ + 'Name' => mb_substr($name, 0, 255), + 'StartDate' => $startDate, // YYYY-MM-DD + 'EndDate' => $endDate, + 'CpmBannerCampaign' => [ + 'BiddingStrategy' => [ + 'Search' => ['BiddingStrategyType' => 'SERVING_OFF'], + 'Network' => [ + 'BiddingStrategyType' => 'CP_MAXIMUM_IMPRESSIONS', + 'CpMaximumImpressions' => [ + 'AverageCpm' => $averageCpmMicros, + 'SpendLimit' => $spendLimitMicros, + 'StartDate' => $startDate, + 'EndDate' => $endDate, + 'AutoContinue' => $autoContinue, // 'YES'|'NO' + ], + ], + ], + 'FrequencyCap' => [ + 'Impressions' => $frequencyImpressions, + 'PeriodDays' => $frequencyPeriodDays, + ], + ], + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** AdGroups.add: пустая медийная (CpmBanner) группа — аудитория цепляется отдельно (addMediaAudienceTarget). + * + * @param array $regionIds + */ + public function addCpmBannerAdGroup(int $campaignId, string $name, array $regionIds): int + { + $res = $this->call('v5/adgroups', 'add', [ + 'AdGroups' => [[ + 'Name' => mb_substr($name, 0, 255), + 'CampaignId' => $campaignId, + 'RegionIds' => array_values($regionIds), + // Пустой JSON-объект {}, НЕ массив [] — иначе API Директа отвергает адгруппу. + 'CpmBannerKeywordsAdGroup' => (object) [], + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** AudienceTargets.add для медийной группы: без ContextBid (ставка — на уровне кампании CPM-стратегии). */ + public function addMediaAudienceTarget(int $adGroupId, int $retargetingListId): int + { + $res = $this->call('v5/audiencetargets', 'add', [ + 'AudienceTargets' => [[ + 'AdGroupId' => $adGroupId, + 'RetargetingListId' => $retargetingListId, + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** Ads.add: CpmBannerAdBuilderAd по готовому креативу (Id из Яндекс.Аудиторий/Директа). */ + public function addCpmBannerAd(int $adGroupId, int $creativeId, string $href): int + { + $res = $this->call('v5/ads', 'add', [ + 'Ads' => [[ + 'AdGroupId' => $adGroupId, + 'CpmBannerAdBuilderAd' => [ + 'Creative' => ['CreativeId' => $creativeId], + 'Href' => mb_substr($href, 0, 1024), + ], + ]], + ]); + + return (int) $res['result']['AddResults'][0]['Id']; + } + + /** + * Creatives.get: превью готового креатива (для показа клиенту). Null, если креатив не найден. + * + * @return array{id: int, type: ?string, preview_url: ?string, thumbnail_url: ?string, is_adaptive: bool, width: ?int, height: ?int}|null + */ + public function getCreativePreview(int $creativeId): ?array + { + $res = $this->call('v5/creatives', 'get', [ + 'SelectionCriteria' => ['Ids' => [$creativeId]], + 'FieldNames' => ['Id', 'Type', 'PreviewUrl', 'ThumbnailUrl', 'IsAdaptive', 'Width', 'Height'], + ]); + + $creative = $res['result']['Creatives'][0] ?? null; + if ($creative === null) { + return null; + } + + return [ + 'id' => (int) $creative['Id'], + 'type' => $creative['Type'] ?? null, + 'preview_url' => $creative['PreviewUrl'] ?? null, + 'thumbnail_url' => $creative['ThumbnailUrl'] ?? null, + 'is_adaptive' => ($creative['IsAdaptive'] ?? null) === 'YES', + 'width' => isset($creative['Width']) ? (int) $creative['Width'] : null, + 'height' => isset($creative['Height']) ? (int) $creative['Height'] : null, + ]; + } + + /** + * Creatives.get: слепок картиночных креативов аккаунта — номер → [ширина, высота]. + * + * Нужен для опознания креативов, загруженных роботом в веб-кабинет: имя у всех + * загруженных одинаковое («Новый креатив» — его ставит Яндекс), поэтому опознаём + * разницей слепков «до/после» и сопоставлением по размеру. Загруженные JPG/PNG/GIF + * Яндекс хранит типом HTML5_CREATIVE — проверено живым запросом 27.07.2026. + * + * Опись идёт СТРАНИЦАМИ: больше PAGE_LIMIT объектов Яндекс за раз не отдаёт, а признак + * «отдал не всё» — поле `LimitedBy` в ответе (номер последнего отданного объекта). + * Оборванный на первой странице слепок тихо ломает опознание: креатив, лежащий за + * границей страницы, отсутствует и в «до», и в «после» — либо выглядит новым на каждом + * заходе. Поэтому листаем до конца. + * + * @return array + */ + public function listImageCreativeIds(): array + { + $out = []; + $offset = 0; + + do { + $res = $this->call('v5/creatives', 'get', [ + 'SelectionCriteria' => [], + 'FieldNames' => ['Id', 'Type', 'Width', 'Height'], + 'Page' => ['Limit' => self::PAGE_LIMIT, 'Offset' => $offset], + ]); + + foreach ($res['result']['Creatives'] ?? [] as $creative) { + if (($creative['Type'] ?? null) !== 'HTML5_CREATIVE') { + continue; + } + $out[(int) $creative['Id']] = [(int) ($creative['Width'] ?? 0), (int) ($creative['Height'] ?? 0)]; + } + + $limitedBy = $res['result']['LimitedBy'] ?? null; + $offset = $limitedBy === null ? null : (int) $limitedBy; + } while ($offset !== null); + + return $out; + } + + /** + * Creatives.get по списку номеров: настоящие размеры креативов — номер → [ширина, высота]. + * Одним запросом на весь набор, чтобы сверка перед созданием объявлений не превращалась + * в пятнадцать обращений подряд. Номера, которых в кабинете нет, в ответе просто + * отсутствуют — вызывающий сам решает, что делать с недостачей. + * + * @param array $ids + * @return array + */ + public function getCreativeSizes(array $ids): array + { + if ($ids === []) { + return []; + } + + $res = $this->call('v5/creatives', 'get', [ + 'SelectionCriteria' => ['Ids' => array_values(array_unique(array_map('intval', $ids)))], + 'FieldNames' => ['Id', 'Width', 'Height'], + ]); + + $out = []; + foreach ($res['result']['Creatives'] ?? [] as $creative) { + $out[(int) $creative['Id']] = [(int) ($creative['Width'] ?? 0), (int) ($creative['Height'] ?? 0)]; + } + + return $out; + } + + /** + * ads.delete — убрать объявления из кабинета. Зовём только для отклонённых: + * они всё равно не показываются и денег не стоят, но копить их в кабинете незачем. + * Пустой список наружу не отправляем: Яндекс ответил бы ошибкой на пустое дело. + * + * @param array $adIds + */ + public function deleteAds(array $adIds): void + { + $adIds = array_values(array_map( + fn ($id) => (int) $id, + array_filter($adIds, fn ($id) => (int) $id > 0), + )); + + if ($adIds === []) { + return; + } + + $this->call('v5/ads', 'delete', ['SelectionCriteria' => ['Ids' => $adIds]]); + } + + /** Campaigns.suspend — пауза показа. */ + public function suspendCampaign(int $campaignId): void + { + $this->call('v5/campaigns', 'suspend', ['SelectionCriteria' => ['Ids' => [$campaignId]]]); + } + + /** Campaigns.resume — возобновление показа. */ + public function resumeCampaign(int $campaignId): void + { + $this->call('v5/campaigns', 'resume', ['SelectionCriteria' => ['Ids' => [$campaignId]]]); + } + + /** + * Reports CAMPAIGN_PERFORMANCE_REPORT за ВСЁ ВРЕМЯ кампании (накопительно). Возвращает + * суммарное число показов — источник для CampaignImpressionCharger, который сам считает + * дельту от уже списанного и идемпотентен по external_key. + * + * ⚠️ TODO(verify): Reports часто отдаёт TSV (не JSON) и может строиться асинхронно + * (HTTP 201/202 + заголовок retryIn) — перед боем сверить формат/async по + * yandex.ru/dev/direct/doc/ru/reports/* и при необходимости добавить ретрай-поллинг. + * Здесь — минимальный синхронный путь (для песочницы/тестов). + */ + public function getCampaignImpressions(int $campaignId): int + { + $resp = Http::withToken($this->token) + ->withHeaders([ + 'skipReportHeader' => 'true', + 'skipReportSummaryRow' => 'true', + 'skipColumnHeader' => 'true', + ]) + ->acceptJson() + ->post(rtrim($this->baseUrl, '/').'/json/v5/reports', ['params' => [ + 'SelectionCriteria' => [ + 'Filter' => [[ + 'Field' => 'CampaignId', + 'Operator' => 'EQUALS', + 'Values' => [(string) $campaignId], + ]], + ], + 'FieldNames' => ['Impressions'], + // ReportName УНИКАЛЕН на каждый вызов: Яндекс кэширует подготовленный отчёт + // по имени, а нам при ALL_TIME нужны свежие накопительные показы каждый прогон — + // иначе повторный запрос вернёт устаревший кэш и списание замрёт (недобор). + 'ReportName' => 'impressions-'.$campaignId.'-'.now()->format('YmdHis'), + 'ReportType' => 'CAMPAIGN_PERFORMANCE_REPORT', + 'DateRangeType' => 'ALL_TIME', + 'Format' => 'TSV', + ]]); + + $body = trim((string) $resp->body()); + if ($body === '') { + return 0; + } + + return (int) $body; + } + + /** + * Общий вызов JSON API. Бросает RuntimeException при error или не-2xx. + * + * @param array $params + * @return array + */ + private function call(string $servicePath, string $method, array $params): array + { + $resp = Http::withToken($this->token) + ->acceptJson() + ->asJson() + ->post(rtrim($this->baseUrl, '/').'/json/'.$servicePath, [ + 'method' => $method, + 'params' => $params, + ]); + + $json = $resp->json(); + if (! $resp->successful() || isset($json['error'])) { + $err = $json['error'] ?? ['error_string' => $resp->body()]; + throw new RuntimeException('Yandex Direct '.$servicePath.'/'.$method.' error: '.json_encode($err, JSON_UNESCAPED_UNICODE)); + } + + return $json; + } +} diff --git a/app/app/Services/Billing/Invoice/InvoicePaymentService.php b/app/app/Services/Billing/Invoice/InvoicePaymentService.php index 8c828f91..96f4094b 100644 --- a/app/app/Services/Billing/Invoice/InvoicePaymentService.php +++ b/app/app/Services/Billing/Invoice/InvoicePaymentService.php @@ -10,19 +10,23 @@ use App\Models\SaasTransaction; use App\Models\SaasUpdDocument; use App\Models\Tenant; use App\Models\User; +use App\Services\Advertising\AdWalletService; use App\Services\Billing\BillingTopupService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; /** * Отметка счёта оплаченным: атомарный claim issued→paid (идемпотентно), - * зачисление баланса (BillingTopupService), создание акта, письмо клиенту. + * зачисление баланса (BillingTopupService — баланс за лиды, либо + * AdWalletService — рекламный кошелёк, по SaasInvoice::credit_target), + * создание акта, письмо клиенту. * Зеркалит идемпотентность и RLS-контекст PaymentWebhookController. */ final class InvoicePaymentService { public function __construct( private readonly BillingTopupService $topup, + private readonly AdWalletService $adWallet, private readonly ActService $acts, ) {} @@ -57,14 +61,35 @@ final class InvoicePaymentService 'completed_at' => now(), ]); - $balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null); - $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id); + // Зачисление денег: маршрут по credit_target. 'leads' (умолчание) — старый путь + // БЕЗ ИЗМЕНЕНИЙ (баланс за лиды tenants.balance_rub через BillingTopupService, + // ledger balance_transactions). 'advertising' — отдельный кошелёк ad_wallets + // (AdWalletService), свой ledger (ad_wallet_transactions); ссылки + // balance_rub_after/balance_transaction_id на SaasTransaction относятся к + // ledger'у ЛИДОВ, поэтому для рекламной ветки не проставляются. + // TODO(В7): формулировка акта для рекламных услуг — по решению бухгалтера. + if ($invoice->credit_target === 'advertising') { + $this->adWallet->topup( + (int) $invoice->tenant_id, + (string) $invoice->amount_total, + null, + 'Оплата по счёту '.$invoice->invoice_number, + ); + $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id); - SaasTransaction::where('id', $tx->id)->update([ - 'balance_rub_after' => $balanceTx->balance_rub_after, - 'balance_transaction_id' => $balanceTx->id, - 'upd_id' => $act->id, - ]); + SaasTransaction::where('id', $tx->id)->update([ + 'upd_id' => $act->id, + ]); + } else { + $balanceTx = $this->topup->topup((int) $invoice->tenant_id, (string) $invoice->amount_total, null); + $act = $this->acts->createForInvoice($invoice->fresh(), (int) $tx->id); + + SaasTransaction::where('id', $tx->id)->update([ + 'balance_rub_after' => $balanceTx->balance_rub_after, + 'balance_transaction_id' => $balanceTx->id, + 'upd_id' => $act->id, + ]); + } SaasInvoice::where('id', $invoice->id)->update(['transaction_id' => $tx->id]); return true; diff --git a/app/app/Services/Billing/Invoice/InvoiceService.php b/app/app/Services/Billing/Invoice/InvoiceService.php index 0f338d77..f083dd0e 100644 --- a/app/app/Services/Billing/Invoice/InvoiceService.php +++ b/app/app/Services/Billing/Invoice/InvoiceService.php @@ -26,7 +26,7 @@ final class InvoiceService private readonly PdfRenderer $pdf, ) {} - public function create(int $tenantId, string $amountRub, ?int $userId): SaasInvoice + public function create(int $tenantId, string $amountRub, ?int $userId, string $creditTarget = 'leads'): SaasInvoice { $req = TenantRequisites::where('tenant_id', $tenantId)->first(); if ($req === null || blank($req->inn)) { @@ -43,7 +43,7 @@ final class InvoiceService $payerEmail = is_string($email) && $email !== '' ? $email : null; } - return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail) { + return DB::transaction(function () use ($tenantId, $amountRub, $req, $seller, $payerEmail, $creditTarget) { $now = Carbon::now('Europe/Moscow'); $number = $this->numbers->next((int) $seller->id, $now); @@ -62,6 +62,7 @@ final class InvoiceService 'vat_amount' => 0, 'amount_total' => $amountRub, 'payment_purpose' => 'Оплата по счёту '.$number.'. '.self::SERVICE_NAME.'. Без НДС.', + 'credit_target' => $creditTarget, 'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => $now, 'expires_at' => $now->copy()->addWeekdays(5), diff --git a/app/app/Services/Billing/OnlineTopupService.php b/app/app/Services/Billing/OnlineTopupService.php index 16bb83c6..8d4b073e 100644 --- a/app/app/Services/Billing/OnlineTopupService.php +++ b/app/app/Services/Billing/OnlineTopupService.php @@ -26,9 +26,14 @@ final class OnlineTopupService PaymentGateway $gateway, string $returnUrl, ?int $userId, + string $creditTarget = SaasTransaction::CREDIT_TARGET_LEADS, ): CreatePaymentResult { $idempotenceKey = (string) Str::uuid(); + $description = $creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING + ? 'Онлайн-пополнение рекламного кошелька' + : 'Онлайн-пополнение баланса'; + $tx = SaasTransaction::create([ 'tenant_id' => $tenantId, 'type' => SaasTransaction::TYPE_TOPUP, @@ -37,8 +42,9 @@ final class OnlineTopupService 'gateway_code' => $gateway->code, 'gateway_idempotence_key' => $idempotenceKey, 'legal_entity_id' => $gateway->legal_entity_id, + 'credit_target' => $creditTarget, 'status' => SaasTransaction::STATUS_PENDING, - 'description' => 'Онлайн-пополнение баланса', + 'description' => $description, 'created_at' => now(), ]); @@ -48,10 +54,14 @@ final class OnlineTopupService $email = $userId !== null ? User::query()->whereKey($userId)->value('email') : null; $email = is_string($email) && $email !== '' ? $email : (string) config('mail.from.address', 'info@liderra.ru'); + $receiptDescription = $creditTarget === SaasTransaction::CREDIT_TARGET_ADVERTISING + ? 'Пополнение рекламного кошелька Лидерра' + : 'Пополнение баланса Лидерра'; + $receipt = [ 'customer' => ['email' => $email], 'items' => [[ - 'description' => 'Пополнение баланса Лидерра', + 'description' => $receiptDescription, 'quantity' => '1.00', 'amount' => ['value' => $amountRub, 'currency' => 'RUB'], 'vat_code' => 1, diff --git a/app/app/Services/Billing/PaymentSettlementService.php b/app/app/Services/Billing/PaymentSettlementService.php index af75d2ae..19dafc02 100644 --- a/app/app/Services/Billing/PaymentSettlementService.php +++ b/app/app/Services/Billing/PaymentSettlementService.php @@ -6,6 +6,7 @@ namespace App\Services\Billing; use App\Models\PaymentGateway; use App\Models\SaasTransaction; +use App\Services\Advertising\AdWalletService; use App\Services\Billing\Gateway\PaymentGatewayDriver; use App\Services\Billing\Gateway\WebhookVerifyResult; use Illuminate\Support\Facades\DB; @@ -29,6 +30,7 @@ final class PaymentSettlementService public function __construct( private readonly PaymentGatewayDriver $driver, private readonly BillingTopupService $topupService, + private readonly AdWalletService $adWallet, ) {} /** Спросить шлюз о судьбе платежа и довести наш учёт до соответствия. */ @@ -112,6 +114,24 @@ final class PaymentSettlementService return; // идемпотентный no-op } + // Развилка по назначению зачисления (рекламный кошелёк, Часть A): 'leads' + // (умолчание) — прежний путь БЕЗ ИЗМЕНЕНИЙ (баланс за лиды, ledger + // balance_transactions, provenance-связка). 'advertising' — свой кошелёк + // ad_wallets (AdWalletService), свой ledger (ad_wallet_transactions); + // balance_rub_after/balance_transaction_id относятся к ledger'у ЛИДОВ, + // поэтому для рекламной ветки не проставляются. + if ((string) ($tx->credit_target ?? SaasTransaction::CREDIT_TARGET_LEADS) === SaasTransaction::CREDIT_TARGET_ADVERTISING) { + $this->adWallet->topup( + (int) $tx->tenant_id, (string) $tx->amount_rub, 'yookassa', 'Пополнение рекламного кошелька картой' + ); + + SaasTransaction::where('id', $tx->id)->update([ + 'payment_method' => $verify->paymentMethod, + ]); + + return; + } + $balanceTx = $this->topupService->topup( (int) $tx->tenant_id, (string) $tx->amount_rub, null ); diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index 521f20e0..1e1a9b25 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -1,6 +1,7 @@ UseAdminConnection::class, 'sales-portal' => EnsureSalesUser::class, 'sales-integration' => SalesIntegrationToken::class, + 'creative-robot' => CreativeRobotToken::class, 'apikey' => ApiKeyAuth::class, ]); @@ -70,6 +72,9 @@ return Application::configure(basePath: dirname(__DIR__)) // Сервис-канал «Поиск → Портал» (Этап 2): защита — сервис-токен // (SalesIntegrationToken), не CSRF. У Python-поиска нет сессии/куки. 'api/sales/integration/*', + // Служебный канал робота-грузчика креативов: у робота нет браузерной сессии + // и CSRF-токена, защита канала — сервис-токен (CreativeRobotToken). + 'api/creative-robot/*', ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/app/bootstrap/providers.php b/app/bootstrap/providers.php index 78cb6c37..309d9d91 100644 --- a/app/bootstrap/providers.php +++ b/app/bootstrap/providers.php @@ -1,9 +1,11 @@ 'impersonation', ], - // Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users. + // Портал отдела продаж (Task 0.3). Bearer-токены для sales_users. // Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins. + // Драйвер 'sales-token' (AppServiceProvider::boot) — ЧИСТО токен, без захода + // в web-сессию: штатный 'sanctum' на stateful-домене lk.liderra.ru подставлял + // App\Models\User из открытого рядом обычного кабинета (инцидент 24.07.2026). 'sales' => [ - 'driver' => 'sanctum', - 'provider' => 'sales_users', + 'driver' => 'sales-token', ], ], diff --git a/app/config/services.php b/app/config/services.php index 9eeab6c8..54df8ec4 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -14,6 +14,12 @@ return [ | */ + // Канал робота-грузчика креативов в веб-кабинет Яндекса. Свой токен, отдельный + // от клиентских и от токена «Поиск → Портал». Пусто → канал закрыт. + 'creative_robot' => [ + 'token' => env('CREATIVE_ROBOT_TOKEN', ''), + ], + // Рекламная аудитория кандидатов (Яндекс.Аудитории). Токен — OAuth Яндекс ID, // выдаётся разово на аккаунт, где живёт сегмент. Пусто → синхронизация не идёт, // джоб пишет причину в last_error и молчит. @@ -22,6 +28,20 @@ return [ 'segment_name' => env('YANDEX_AUDIENCE_SEGMENT_NAME', 'Лидерра — кандидаты'), ], + // Яндекс.Директ (B1) — рекламный кошелёк. ПЕСОЧНИЦА по умолчанию (В8): боевой — + // сменой env на api.direct.yandex.com после одобрения заявки. Денежный рубильник + // enabled: пока выкл — реальные вызовы Директа не идут (как enabled у прогрева). + 'yandex_direct' => [ + 'base_url' => env('YANDEX_DIRECT_BASE_URL', 'https://api-sandbox.direct.yandex.com'), + 'token' => env('YANDEX_DIRECT_TOKEN'), + 'enabled' => env('YANDEX_DIRECT_ENABLED', false), + // Регионы показа по умолчанию (вся Россия = 225). + 'region_ids' => [225], + // Предохранитель: SpendLimit в Яндексе = яндекс-бюджет × множитель (бэкстоп + // от перерасхода, НЕ клиентская цена). + 'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2), + ], + // Рекламная аудитория кандидатов в ВК (Task 8, 21.07.2026). Программный доступ // запрошен 19.07, ждём проверки реквизитов — до получения токена джоб пишет // vk_status=no_access и не делает ни одного обращения к ВК. diff --git a/app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php b/app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php new file mode 100644 index 00000000..f93d6d95 --- /dev/null +++ b/app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php @@ -0,0 +1,30 @@ +id(); + $table->decimal('markup_percent', 5, 2)->default('30.00'); // глобальная наценка рекламы + $table->timestamps(); + }); + DB::table('ad_settings')->insert(['markup_percent' => '30.00', 'created_at' => now(), 'updated_at' => now()]); + // ГЛОБАЛЬНАЯ настройка (одна строка на всю систему), НЕ tenant-scoped: нет tenant_id, + // поэтому RLS НЕ вешаем осознанно. Читает app-роль, редактирует только админ. + DB::statement('GRANT SELECT ON ad_settings TO crm_app_user'); + DB::statement('GRANT SELECT, UPDATE ON ad_settings TO crm_admin_user'); + } + + public function down(): void + { + Schema::dropIfExists('ad_settings'); + } +}; diff --git a/app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php b/app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php new file mode 100644 index 00000000..8d9ae7e1 --- /dev/null +++ b/app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('channel', 16)->default('yandex'); + $table->string('name'); + // draft|pending_moderation|running|paused|rejected|stopped_no_funds + $table->string('status', 24)->default('draft'); + $table->unsignedSmallInteger('audience_days')->default(10); + $table->boolean('use_uploaded_list')->default(false); + $table->decimal('weekly_budget_rub', 14, 2); + $table->decimal('daily_budget_rub', 14, 2)->nullable(); + $table->decimal('click_bid_rub', 14, 2)->nullable(); + $table->unsignedBigInteger('yandex_segment_id')->nullable(); + $table->unsignedBigInteger('yandex_retargeting_list_id')->nullable(); + $table->unsignedBigInteger('yandex_campaign_id')->nullable(); + $table->unsignedBigInteger('yandex_ad_group_id')->nullable(); + $table->string('moderation_reason')->nullable(); + $table->timestamp('launched_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'status']); + }); + + DB::statement('ALTER TABLE ad_campaigns ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_campaigns FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaigns'); + DB::statement("CREATE POLICY tenant_isolation ON ad_campaigns USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('ad_campaigns'); + } +}; diff --git a/app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php b/app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php new file mode 100644 index 00000000..f84ffb65 --- /dev/null +++ b/app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete(); + $table->string('title', 56); + $table->string('title2', 45)->nullable(); + $table->string('text', 96); + $table->string('href', 1024); + $table->string('image_normal_hash')->nullable(); + $table->string('image_wide_hash')->nullable(); + $table->unsignedBigInteger('yandex_ad_id')->nullable(); + $table->string('moderation_status', 16)->default('draft'); + $table->string('moderation_reason')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + }); + + DB::statement('ALTER TABLE ad_campaign_ads ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_campaign_ads FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_ads'); + DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_ads USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_campaign_ads TO crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('ad_campaign_ads'); + } +}; diff --git a/app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php b/app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php new file mode 100644 index 00000000..c3cd3651 --- /dev/null +++ b/app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete(); + $table->string('phone', 11); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + $table->unique(['tenant_id', 'campaign_id', 'phone']); + }); + + DB::statement('ALTER TABLE ad_campaign_phones ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_campaign_phones FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_phones'); + DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_phones USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON ad_campaign_phones TO crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('ad_campaign_phones'); + } +}; diff --git a/app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php b/app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php new file mode 100644 index 00000000..20f726db --- /dev/null +++ b/app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php @@ -0,0 +1,20 @@ +decimal('client_cpm_rub', 8, 2)->default('120.00')->after('markup_percent'); + }); + + DB::table('ad_settings')->update(['client_cpm_rub' => '120.00', 'updated_at' => now()]); + } + + public function down(): void + { + Schema::table('ad_settings', function (Blueprint $table) { + $table->dropColumn('client_cpm_rub'); + }); + } +}; diff --git a/app/database/migrations/2026_07_26_100100_add_impression_fields_to_ad_campaigns.php b/app/database/migrations/2026_07_26_100100_add_impression_fields_to_ad_campaigns.php new file mode 100644 index 00000000..484dbce6 --- /dev/null +++ b/app/database/migrations/2026_07_26_100100_add_impression_fields_to_ad_campaigns.php @@ -0,0 +1,39 @@ +unsignedSmallInteger('frequency')->nullable()->after('use_uploaded_list'); + $table->unsignedSmallInteger('frequency_period_days')->nullable()->after('frequency'); + $table->unsignedBigInteger('estimated_impressions')->nullable()->after('frequency_period_days'); + $table->unsignedBigInteger('paid_impressions')->nullable()->after('estimated_impressions'); + $table->unsignedBigInteger('delivered_impressions')->default(0)->after('paid_impressions'); + $table->decimal('budget_rub', 14, 2)->nullable()->after('delivered_impressions'); + $table->decimal('yandex_cost_rub', 14, 2)->default('0.00')->after('budget_rub'); + $table->decimal('charged_client_rub', 14, 2)->default('0.00')->after('yandex_cost_rub'); + + // Клик-наследие больше не обязательно (клик-поля удалим отдельной уборкой позже). + $table->decimal('weekly_budget_rub', 14, 2)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn([ + 'frequency', 'frequency_period_days', 'estimated_impressions', + 'paid_impressions', 'delivered_impressions', 'budget_rub', + 'yandex_cost_rub', 'charged_client_rub', + ]); + }); + } +}; diff --git a/app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php b/app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php new file mode 100644 index 00000000..c94ad66a --- /dev/null +++ b/app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete(); + $table->unsignedSmallInteger('width'); + $table->unsignedSmallInteger('height'); + $table->string('path'); + $table->unsignedInteger('bytes'); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + }); + + DB::statement('ALTER TABLE ad_campaign_banners ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_campaign_banners FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_campaign_banners'); + DB::statement("CREATE POLICY tenant_isolation ON ad_campaign_banners USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, DELETE ON ad_campaign_banners TO crm_app_user'); + } + + public function down(): void + { + Schema::dropIfExists('ad_campaign_banners'); + } +}; diff --git a/app/database/migrations/2026_07_26_100300_add_banners_approved_at_to_ad_campaigns.php b/app/database/migrations/2026_07_26_100300_add_banners_approved_at_to_ad_campaigns.php new file mode 100644 index 00000000..0d0c9d1f --- /dev/null +++ b/app/database/migrations/2026_07_26_100300_add_banners_approved_at_to_ad_campaigns.php @@ -0,0 +1,24 @@ +timestamp('banners_approved_at')->nullable()->after('charged_client_rub'); + }); + } + + public function down(): void + { + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn('banners_approved_at'); + }); + } +}; diff --git a/app/database/migrations/2026_07_26_100400_add_included_to_ad_campaign_banners.php b/app/database/migrations/2026_07_26_100400_add_included_to_ad_campaign_banners.php new file mode 100644 index 00000000..5cacc6da --- /dev/null +++ b/app/database/migrations/2026_07_26_100400_add_included_to_ad_campaign_banners.php @@ -0,0 +1,24 @@ +boolean('included')->default(true)->after('bytes'); + }); + DB::statement('GRANT UPDATE ON ad_campaign_banners TO crm_app_user'); + } + + public function down(): void + { + Schema::table('ad_campaign_banners', fn (Blueprint $t) => $t->dropColumn('included')); + } +}; diff --git a/app/database/migrations/2026_07_26_100500_add_mode_price_to_ad_campaigns.php b/app/database/migrations/2026_07_26_100500_add_mode_price_to_ad_campaigns.php new file mode 100644 index 00000000..8707f950 --- /dev/null +++ b/app/database/migrations/2026_07_26_100500_add_mode_price_to_ad_campaigns.php @@ -0,0 +1,31 @@ +string('mode', 10)->default('auto')->after('name'); // 'auto' | 'manual' + $table->date('snapshot_from')->nullable()->after('audience_days'); // ручной: период сделок + $table->date('snapshot_to')->nullable()->after('snapshot_from'); + $table->smallInteger('run_days')->nullable()->after('snapshot_to'); // ручной: срок показа, дней + $table->decimal('client_cpm_rub', 8, 2)->nullable()->after('budget_rub'); // цена клиента; NULL → дефолт из ad_settings + }); + } + + public function down(): void + { + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn(['mode', 'snapshot_from', 'snapshot_to', 'run_days', 'client_cpm_rub']); + }); + } +}; diff --git a/app/database/migrations/2026_07_26_100600_add_margin_to_ad_settings.php b/app/database/migrations/2026_07_26_100600_add_margin_to_ad_settings.php new file mode 100644 index 00000000..3dba6b7a --- /dev/null +++ b/app/database/migrations/2026_07_26_100600_add_margin_to_ad_settings.php @@ -0,0 +1,25 @@ + $table->decimal('ad_margin_percent', 5, 2)->default(40.00)); + } + + public function down(): void + { + Schema::table('ad_settings', function (Blueprint $table) { + $table->dropColumn('ad_margin_percent'); + }); + } +}; diff --git a/app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php b/app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php new file mode 100644 index 00000000..e5ec2be6 --- /dev/null +++ b/app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php @@ -0,0 +1,54 @@ +unsignedBigInteger('yandex_creative_id')->nullable()->after('yandex_ad_group_id'); + }); + + // Оператор вписывает номер креатива в админ-экране «Рекламные кампании» под ролью + // crm_admin_user (на Managed-кластере НЕ BYPASSRLS). У неё на ad_campaigns был только + // SELECT (grant_admin_read_advertising) → нужен UPDATE. Даём ТОЧЕЧНО на одну колонку + // (least privilege) — оператор правит только номер креатива, не остальные поля кампании. + // crm_app_user (клиент) и crm_supplier_worker (робот-джобы) уже имеют табличный UPDATE. + // Гард на существование роли: на dev/test роли нет (DB_USERNAME=postgres superuser). + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + REVOKE UPDATE (yandex_creative_id) ON ad_campaigns FROM crm_admin_user; + END IF; + END + $$; + SQL); + + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn('yandex_creative_id'); + }); + } +}; diff --git a/app/database/migrations/2026_07_26_101500_add_landing_url_and_yandex_ad_id_to_ad_campaigns.php b/app/database/migrations/2026_07_26_101500_add_landing_url_and_yandex_ad_id_to_ad_campaigns.php new file mode 100644 index 00000000..cf460ab4 --- /dev/null +++ b/app/database/migrations/2026_07_26_101500_add_landing_url_and_yandex_ad_id_to_ad_campaigns.php @@ -0,0 +1,32 @@ +string('landing_url', 1024)->nullable()->after('client_cpm_rub'); + $table->unsignedBigInteger('yandex_ad_id')->nullable()->after('yandex_creative_id'); + }); + } + + public function down(): void + { + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn(['landing_url', 'yandex_ad_id']); + }); + } +}; diff --git a/app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php b/app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php new file mode 100644 index 00000000..a760f9f5 --- /dev/null +++ b/app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php @@ -0,0 +1,76 @@ +unsignedBigInteger('yandex_creative_id')->nullable()->after('bytes'); + } + if (! Schema::hasColumn('ad_campaign_banners', 'yandex_ad_id')) { + $table->unsignedBigInteger('yandex_ad_id')->nullable()->after('yandex_creative_id'); + } + if (! Schema::hasColumn('ad_campaign_banners', 'moderation_status')) { + $table->string('moderation_status', 16)->default('draft')->after('yandex_ad_id'); + } + if (! Schema::hasColumn('ad_campaign_banners', 'moderation_reason')) { + $table->string('moderation_reason')->nullable()->after('moderation_status'); + } + }); + + // 🔴 На проде crm_supplier_worker и crm_admin_user НЕ BYPASSRLS. Табличные GRANT у + // ad_campaign_banners уже есть у crm_app_user; джобам модерации нужен UPDATE через + // pgsql_supplier. Кросс-тенантный доступ служебных ролей даёт db/03_service_bypass_policies.sql — + // после выката ПЕРЕзапустить его, иначе джоб молча увидит ноль строк. + // + // Гард на pg_roles — на случай окружения, где служебной роли нет: GRANT на несуществующую + // роль роняет миграцию. На liderra и liderra_testing роль есть, гард просто не срабатывает + // (тот же паттерн, что и в 2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php). + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_supplier_worker; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN + REVOKE SELECT, UPDATE ON ad_campaign_banners FROM crm_supplier_worker; + END IF; + END + $$; + SQL); + + Schema::table('ad_campaign_banners', function (Blueprint $table) { + $table->dropColumn(['yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason']); + }); + } +}; diff --git a/app/database/migrations/2026_07_27_100100_grant_admin_ad_campaign_banners.php b/app/database/migrations/2026_07_27_100100_grant_admin_ad_campaign_banners.php new file mode 100644 index 00000000..9b91a0dc --- /dev/null +++ b/app/database/migrations/2026_07_27_100100_grant_admin_ad_campaign_banners.php @@ -0,0 +1,52 @@ +id()` — это bigserial, + * то есть рядом с таблицей живёт отдельный объект-нумератор `<таблица>_id_seq`, который + * выдаёт следующий номер строки. В PostgreSQL это ОТДЕЛЬНЫЙ объект со своими правами: + * `GRANT INSERT ON <таблица>` разрешает вставку, но НЕ разрешает взять следующий номер. + * Без `USAGE` на нумераторе INSERT падает с «permission denied for sequence». + * + * ── Почему это не поймали раньше ────────────────────────────────────────────── + * На разработке и в тестах приложение подключается суперпользователем (`postgres`), + * а суперпользователю права не проверяются вообще. Поэтому дырка невидима при полностью + * зелёных тестах и вылезает только на бою, где ходят настоящие ограниченные роли. + * Тот же класс ошибки уже ловили раньше — он прямо назван в db/CHANGELOG_schema.md + * (v8.82/v8.84): «пропущенный sequence-grant = permission denied на бою, тестам на + * dev/суперюзере невидимо». + * + * ── Корневая причина ────────────────────────────────────────────────────────── + * В `db/02_grants.sql` права раздаются двумя способами: + * 1) `GRANT ... ON ALL TABLES/SEQUENCES IN SCHEMA public` — разово, только по тем + * объектам, что существуют в момент запуска файла; + * 2) `ALTER DEFAULT PRIVILEGES IN SCHEMA public ...` — БЕЗ `FOR ROLE crm_migrator`. + * Из-за отсутствия `FOR ROLE` дефолтные привилегии привязаны к тому, кто запускает файл, + * а не к `crm_migrator`, под которым идут миграции. Итог: всё, что миграции создают после + * последнего запуска `02_grants.sql`, дефолтных прав НЕ наследует — гранты приходится + * выдавать вручную в каждой миграции, и один из них (на нумератор) забыли. + * Системное лечение — дописать `FOR ROLE crm_migrator` в `db/02_grants.sql`. Это базовый + * файл прав, он правится только по согласованию с владельцем, поэтому здесь — только + * страховка по факту, а не корневой фикс. + * + * ── Кому выдаём и почему именно им ──────────────────────────────────────────── + * Нумератор нужен только тому, кто делает INSERT. По фактическому состоянию прав + * (`information_schema.role_table_grants` на `liderra_testing`) INSERT на рекламные + * таблицы есть ровно у одной роли — `crm_app_user` (клиентский портал): + * ad_campaigns, ad_campaign_ads, ad_campaign_phones, ad_campaign_banners, + * ad_wallets, ad_wallet_transactions, ad_wallet_holds. + * `crm_supplier_worker` и `crm_admin_user` имеют только SELECT/UPDATE — им нумератор + * не нужен, лишнего не раздаём (least privilege). Джобы Директа перечисляют кампании + * через `pgsql_supplier`, но само списание (вставка в `ad_wallet_transactions`) идёт + * основным соединением под `crm_app_user` — см. `ChargeCampaignSpendJob::handle()`. + * `ad_settings` в списке НЕТ: таблица однострочная, INSERT туда не выдан никому. + * + * ── Безопасность ────────────────────────────────────────────────────────────── + * Миграция строго аддитивна: ни колонок, ни таблиц, ни RLS-политик она не трогает. + * `GRANT` идемпотентен — если право уже выдано (например, на бою `02_grants.sql` + * гонялся позже создания таблиц), повторная выдача ничего не меняет, это безвредный + * no-op. Гарды: на существование роли (на dev/test её может не быть — там superuser) + * и на существование самого нумератора (окружение, где часть таблиц ещё/уже не создана). + * + * CHANGELOG: v9.05. + */ +return new class extends Migration +{ + public function up(): void + { + DB::statement(<<<'SQL' + DO $$ + DECLARE + seq TEXT; + seqs TEXT[] := ARRAY[ + 'ad_campaigns_id_seq', + 'ad_campaign_ads_id_seq', + 'ad_campaign_phones_id_seq', + 'ad_campaign_banners_id_seq', + 'ad_wallets_id_seq', + 'ad_wallet_transactions_id_seq', + 'ad_wallet_holds_id_seq' + ]; + BEGIN + -- Гард на роль: GRANT несуществующей роли роняет миграцию (dev/test = postgres + -- superuser). Тот же паттерн, что в 2026_07_27_100100_grant_admin_ad_campaign_banners.php. + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + RETURN; + END IF; + + FOREACH seq IN ARRAY seqs LOOP + -- Гард на нумератор: таблицы на этом окружении может ещё/уже не быть. + IF EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = seq AND c.relkind = 'S' AND n.nspname = 'public' + ) THEN + EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE public.%I TO crm_app_user', seq); + END IF; + END LOOP; + END + $$; + SQL); + } + + public function down(): void + { + DB::statement(<<<'SQL' + DO $$ + DECLARE + seq TEXT; + seqs TEXT[] := ARRAY[ + 'ad_campaigns_id_seq', + 'ad_campaign_ads_id_seq', + 'ad_campaign_phones_id_seq', + 'ad_campaign_banners_id_seq', + 'ad_wallets_id_seq', + 'ad_wallet_transactions_id_seq', + 'ad_wallet_holds_id_seq' + ]; + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + RETURN; + END IF; + + FOREACH seq IN ARRAY seqs LOOP + IF EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = seq AND c.relkind = 'S' AND n.nspname = 'public' + ) THEN + EXECUTE format('REVOKE USAGE, SELECT ON SEQUENCE public.%I FROM crm_app_user', seq); + END IF; + END LOOP; + END + $$; + SQL); + } +}; diff --git a/app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php b/app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php new file mode 100644 index 00000000..200a2bf4 --- /dev/null +++ b/app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php @@ -0,0 +1,104 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete(); + $table->string('status', 16)->default('queued'); // queued → taken → done | failed + $table->unsignedSmallInteger('attempts')->default(0); + $table->jsonb('snapshot_before')->nullable(); // номера картиночных креативов ДО загрузки: номер → [ширина, высота] + $table->string('failure_reason', 1024)->nullable(); + $table->timestamp('taken_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + $table->index('status'); + }); + + DB::statement('ALTER TABLE ad_creative_jobs ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_creative_jobs FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_creative_jobs'); + DB::statement("CREATE POLICY tenant_isolation ON ad_creative_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + // Гранты — внутри DO $$ с гардом на роль: на dev/test приложение ходит суперпользователем + // postgres, ролей там может не быть, и голый GRANT уронил бы миграцию (паттерн — + // 2026_07_25_100400_grant_supplier_worker_advertising.php). + DB::statement(<<<'SQL' + DO $$ + BEGIN + -- crm_app_user — клиентский портал, он ставит задания при запуске кампании (задача 10). + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT, INSERT, UPDATE ON ad_creative_jobs TO crm_app_user; + END IF; + + -- crm_admin_user — под этой ролью работает служебный канал робота-грузчика. + -- Маршруты /api/creative-robot/* идут через посредник `admin-db` + -- (App\Http\Middleware\UseAdminConnection), а он подменяет активное подключение + -- на pgsql_admin = crm_admin_user. Тот же порядок, что у канала «Поиск → Портал» + -- (routes/web.php, группа api/sales/integration). Робот заданий НЕ создаёт — + -- только берёт очередное (queued→taken) и отчитывается (taken→done|failed), + -- поэтому INSERT ему сознательно не даём: только SELECT/UPDATE (least privilege, + -- обоснование — db/CHANGELOG_schema.md v9.06). + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT, UPDATE ON ad_creative_jobs TO crm_admin_user; + END IF; + END + $$; + SQL); + + // Нумератор (bigserial) — отдельный объект `ad_creative_jobs_id_seq` со своими правами. + // GRANT INSERT на таблицу НЕ даёт право взять следующий номер — без USAGE на нумераторе + // INSERT падает на бою с «permission denied for sequence» (этот класс ошибки уже поймали + // и разобрали в 2026_07_27_100200_grant_ad_sequences.php / CHANGELOG v9.05; на dev/тестах + // дырка невидима, потому что там ходит суперпользователь postgres, которому права не + // проверяются). Нумератор выдаём только crm_app_user — только у него есть INSERT. + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + IF EXISTS ( + SELECT 1 + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'ad_creative_jobs_id_seq' AND c.relkind = 'S' AND n.nspname = 'public' + ) THEN + GRANT USAGE, SELECT ON SEQUENCE public.ad_creative_jobs_id_seq TO crm_app_user; + END IF; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + // Вместе с таблицей уходят и политика, и гранты на таблицу/нумератор — отдельный REVOKE не нужен. + Schema::dropIfExists('ad_creative_jobs'); + } +}; diff --git a/app/database/migrations/2026_07_27_120000_add_shows_until_to_ad_campaigns.php b/app/database/migrations/2026_07_27_120000_add_shows_until_to_ad_campaigns.php new file mode 100644 index 00000000..7a8f25ec --- /dev/null +++ b/app/database/migrations/2026_07_27_120000_add_shows_until_to_ad_campaigns.php @@ -0,0 +1,37 @@ +date('shows_until')->nullable()->after('run_days'); + }); + } + + public function down(): void + { + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn('shows_until'); + }); + } +}; diff --git a/app/database/migrations/2026_07_27_120100_add_single_taken_guard_to_ad_creative_jobs.php b/app/database/migrations/2026_07_27_120100_add_single_taken_guard_to_ad_creative_jobs.php new file mode 100644 index 00000000..2c1cf728 --- /dev/null +++ b/app/database/migrations/2026_07_27_120100_add_single_taken_guard_to_ad_creative_jobs.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/app/database/migrations/2026_07_28_110000_add_revived_at_to_ad_campaigns.php b/app/database/migrations/2026_07_28_110000_add_revived_at_to_ad_campaigns.php new file mode 100644 index 00000000..5ce5de91 --- /dev/null +++ b/app/database/migrations/2026_07_28_110000_add_revived_at_to_ad_campaigns.php @@ -0,0 +1,40 @@ +timestampTz('revived_at')->nullable()->after('launched_at'); + }); + } + + public function down(): void + { + Schema::table('ad_campaigns', function (Blueprint $table) { + $table->dropColumn('revived_at'); + }); + } +}; diff --git a/app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php b/app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php new file mode 100644 index 00000000..50c0dba1 --- /dev/null +++ b/app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php @@ -0,0 +1,56 @@ +messages()->findOrFail($id)`), проверяя и + * тенанта, и кампанию. Подробности — db/CHANGELOG_schema.md v9.12. + * + * CHANGELOG: v9.12. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::table('ad_creative_jobs', function (Blueprint $table) { + $table->string('kind', 16)->default('upload'); // upload | inspect | deliver + $table->foreignId('message_id')->nullable()->constrained('ad_campaign_messages')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('ad_creative_jobs', function (Blueprint $table) { + $table->dropConstrainedForeignId('message_id'); + $table->dropColumn('kind'); + }); + } +}; diff --git a/app/database/migrations/2026_07_29_100100_guard_creative_job_message_belongs_to_campaign.php b/app/database/migrations/2026_07_29_100100_guard_creative_job_message_belongs_to_campaign.php new file mode 100644 index 00000000..8521c329 --- /dev/null +++ b/app/database/migrations/2026_07_29_100100_guard_creative_job_message_belongs_to_campaign.php @@ -0,0 +1,77 @@ +banners()->where('yandex_ad_id', ...)`) — чужой баннер так не подцепится. + * + * Индекс по (campaign_id, kind, yandex_ad_id) — под тот самый запрос дедупа. + * + * Гранты не нужны: колонка добавляется в таблицу, права на которую уже выданы. + * Перезапуск `db/03_service_bypass_policies.sql` не требуется — новых таблиц нет. + * + * CHANGELOG: v9.16. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::table('ad_creative_jobs', function (Blueprint $table) { + // 🪤 ->after() на PostgreSQL — пустое место: колонка встаёт в конец таблицы. + // Так же написаны все соседние миграции проекта. + $table->unsignedBigInteger('yandex_ad_id')->nullable(); + $table->index(['campaign_id', 'kind', 'yandex_ad_id'], 'idx_creative_job_inspect'); + }); + } + + public function down(): void + { + Schema::table('ad_creative_jobs', function (Blueprint $table) { + $table->dropIndex('idx_creative_job_inspect'); + $table->dropColumn('yandex_ad_id'); + }); + } +}; diff --git a/app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php b/app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php new file mode 100644 index 00000000..62722bb5 --- /dev/null +++ b/app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php @@ -0,0 +1,77 @@ + { + const { data } = await apiClient.get('/api/admin/advertising/spend', { + params: { period }, + }); + return data; +} + +export interface AdminAdvertisingSettings { + client_cpm_rub: string; + ad_margin_percent: string; +} + +export async function fetchAdSettings(): Promise { + const { data } = await apiClient.get('/api/admin/advertising/settings'); + return data; +} + +export async function updateAdSettings( + payload: Partial, +): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.put('/api/admin/advertising/settings', payload); + return data; +} + +// === SaaS-admin → Реклама: кампании, ждущие номер креатива Яндекса (8e) === + +export interface AdminAwaitingCampaign { + id: number; + tenant_id: number; + tenant_name: string; + name: string; + status: string; + yandex_creative_id: number | null; + landing_url: string | null; + estimated_impressions: number | null; +} + +export async function fetchAdAwaitingCampaigns(): Promise { + const { data } = await apiClient.get<{ data: AdminAwaitingCampaign[] }>('/api/admin/advertising/campaigns'); + return data.data ?? []; +} + +// === SaaS-admin → Реклама: «ждёт разбора» — сбойные задания робота (задача 15) === + +/** + * Задание робота, которое закрылось сбоем. Это НЕ обычный отказ модерации — те клиент + * разбирает сам по причине в переписке. Это место, где цепочка встала: робот сходил + * в кабинет и не понял, что видит, либо не смог отвезти картинки. + */ +export interface AdminStuckRobotJob { + id: number; + campaign_id: number; + tenant_id: number; + tenant_name: string; + campaign_name: string; + campaign_status: string; + kind: string; + yandex_ad_id: number | null; + failure_reason: string | null; + attempts: number; + finished_at: string | null; +} + +export async function fetchAdRobotStuck(): Promise { + const { data } = await apiClient.get<{ data: AdminStuckRobotJob[] }>('/api/admin/advertising/robot-stuck'); + return data.data ?? []; +} + +export async function setAdCampaignCreative( + id: number, + yandexCreativeId: number, +): Promise<{ id: number; yandex_creative_id: number }> { + await ensureCsrfCookie(); + const { data } = await apiClient.patch<{ id: number; yandex_creative_id: number }>( + `/api/admin/advertising/campaigns/${id}/creative`, + { yandex_creative_id: yandexCreativeId }, + ); + return data; +} + // === SaaS-admin → Инциденты === export interface ApiAdminIncident { diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts new file mode 100644 index 00000000..b73c8871 --- /dev/null +++ b/app/resources/js/api/advertising.ts @@ -0,0 +1,418 @@ +import { apiClient, ensureCsrfCookie } from './client'; + +/** + * API-модуль рекламного модуля «Яндекс Аудитория» (Часть B2, Task 1). + * + * Эндпоинты под [auth:sanctum, tenant], префикс /api/advertising (см. Часть B1 — + * docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md Task 12, + * routes/web.php). GET'ы не требуют CSRF-cookie, мутации (POST/PATCH) — требуют. + */ + +/** Статус кампании Директа (см. App\Models\AdCampaign константы STATUS_*). */ +export type CampaignStatus = + | 'draft' + | 'queued' + // Промежуточный: запуск идёт прямо сейчас. Держится секунды и защищает от двойного + // нажатия «запустить» (иначе в кабинете Яндекса завелись бы две одинаковые кампании). + | 'launching' + | 'pending_moderation' + | 'running' + | 'paused' + | 'rejected' + | 'stopped_no_funds' + | 'completed'; + +/** Ответ GET /api/advertising/wallet — статус рекламного кошелька тенанта. */ +export interface AdWalletStatus { + solvent: boolean; + balance_rub: string; + frozen_rub: string; + free_rub: string; +} + +/** + * Кампания «за показы» (строка из GET /api/advertising/campaigns, а также + * POST/PATCH-ответ). `yandex_cost_rub` (наша маржа) сюда НИКОГДА не + * добавляется — модель прячет поле в сериализации (Ч.5b Task 1, `$hidden`), + * клиент его физически не видит. + */ +export interface Campaign { + id: number; + name: string; + status: CampaignStatus; + audience_days: number; + launched_at: string | null; + /** Частота показов на человека за период (Ч.5b). */ + frequency?: number; + /** Сколько показов ожидаем — смета с сервера (Ч.5b, из audience-size). */ + estimated_impressions?: number; + /** Итоговый бюджет показов клиента — та же смета, что и estimated_impressions. */ + budget_rub?: string; + /** Сколько показов фактически откручено на данный момент (Ч.6, CampaignImpressionCharger). */ + delivered_impressions?: number; + /** + * Приходят и в POST/PATCH-ответе, и в GET .../{id} (detail, controller::show + * отдаёт модель целиком) — но НЕ в списке GET /campaigns (index явно + * выбирает узкий набор колонок), поэтому опциональны. + */ + use_uploaded_list?: boolean; + /** Legacy-поле модели клик-кампаний — оставлено опциональным, новый мастер его не шлёт/не читает. */ + weekly_budget_rub?: string; + /** Пояснение модератора Яндекса — подпись под ярлыком «Отклонено». Полный текст живёт в переписке. */ + moderation_reason?: string | null; + /** Режим сбора аудитории — 'auto' (крутится постоянно, окно дней) или 'manual' (разовый снимок + свои номера). */ + mode?: 'auto' | 'manual'; + /** Начало периода снимка контактов (режим manual). */ + snapshot_from?: string | null; + /** Конец периода снимка контактов (режим manual, >= snapshot_from). */ + snapshot_to?: string | null; + /** Сколько дней показывать рекламу после снимка (режим manual). */ + run_days?: number | null; + /** Клиентская цена за 1000 показов, ₽ — правится клиентом, дефолт с сервера. */ + client_cpm_rub?: string | null; + /** Адрес сайта, куда ведёт баннер по клику. */ + landing_url?: string | null; +} + +/** Ответ GET /api/advertising/campaigns/{id}/audience-size. */ +export interface AudienceSize { + size: number; + min: number; + enough: boolean; + hint: string | null; + frequency?: number; + impressions?: number; + cpm_rub?: string; + cost_rub?: string; +} + +/** Тело POST/PATCH /api/advertising/campaigns (создание/правка черновика кампании «за показы»). */ +export interface CampaignCreate { + name: string; + audience_days: number; + use_uploaded_list: boolean; + /** Частота показов на человека (Ч.5b, шаг «Как часто показывать»). */ + frequency?: number; + frequency_period_days?: number; + /** Смета показов/бюджета — берём из ответа audience-size, фронт не считает сам (Р37). */ + estimated_impressions?: number; + budget_rub?: string; + /** Режим сбора аудитории — 'auto' (окно дней, крутится постоянно) или 'manual' (снимок дат + свои номера). */ + mode?: 'auto' | 'manual'; + /** Начало периода снимка контактов (режим manual, YYYY-MM-DD). */ + snapshot_from?: string | null; + /** Конец периода снимка контактов (режим manual, YYYY-MM-DD, >= snapshot_from). */ + snapshot_to?: string | null; + /** Сколько дней показывать рекламу после снимка (режим manual, 1..365). */ + run_days?: number | null; + /** Клиентская цена за 1000 показов, ₽ — правится клиентом на шаге 2. */ + client_cpm_rub?: string | null; + /** Адрес сайта, куда ведёт баннер по клику. */ + landing_url?: string | null; +} + +/** Объявление кампании (ответ POST /api/advertising/campaigns/{id}/ads). */ +export interface AdCreative { + id: number; + campaign_id: number; + title: string; + text: string; + href: string; + title2: string | null; + moderation_status: string; + /** Причина отказа модерации Яндекса — приходит вместе с moderation_status='rejected'. */ + moderation_reason?: string | null; + image_normal_hash?: string | null; +} + +/** Ответ GET /api/advertising/campaigns/{id} — детали кампании + объявления + расход. */ +export interface CampaignDetail { + campaign: Campaign; + ads: AdCreative[]; + spent_rub: string; +} + +/** GET /api/advertising/wallet — статус рекламного кошелька (баланс/заморожено/свободно). */ +export async function fetchWallet(): Promise { + const { data } = await apiClient.get('/api/advertising/wallet'); + return data; +} + +/** GET /api/advertising/campaigns — список кампаний тенанта. */ +export async function fetchCampaigns(): Promise { + const { data } = await apiClient.get<{ data: Campaign[] }>('/api/advertising/campaigns'); + return data.data ?? []; +} + +/** GET /api/advertising/campaigns/{id} — детали кампании (объявления + расход). */ +export async function fetchCampaign(id: number): Promise { + const { data } = await apiClient.get(`/api/advertising/campaigns/${id}`); + return data; +} + +/** POST /api/advertising/campaigns — создать черновик кампании. */ +export async function createCampaign(payload: CampaignCreate): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/advertising/campaigns', payload); + return data; +} + +/** PATCH /api/advertising/campaigns/{id} — частичная правка кампании. */ +export async function patchCampaign(id: number, payload: Partial): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.patch(`/api/advertising/campaigns/${id}`, payload); + return data; +} + +/** Параметры GET /api/advertising/campaigns/{id}/audience-size — оба режима сбора аудитории + клиентская цена. */ +export interface AudienceSizeParams { + /** Окно дней (режим auto). */ + days?: number; + /** Частота показов на человека — нужна для сметы показов/бюджета на шаге 2. */ + frequency?: number; + /** Режим сбора аудитории — определяет, что сервер трактует days vs from/to. */ + mode?: 'auto' | 'manual' | string; + /** Начало периода снимка (режим manual). */ + from?: string | null; + /** Конец периода снимка (режим manual). */ + to?: string | null; + /** Клиентская цена за 1000 показов — если не задана, сервер применяет дефолт. */ + cpm?: string | null; +} + +/** GET /api/advertising/campaigns/{id}/audience-size — живой счётчик аудитории (+ показы/цена/итого при заданных параметрах). */ +export async function fetchAudienceSize(id: number, params: AudienceSizeParams = {}): Promise { + const query: Record = {}; + if (params.days !== undefined) query.days = params.days; + if (params.frequency !== undefined) query.frequency = params.frequency; + if (params.mode !== undefined) query.mode = params.mode; + if (params.from) query.from = params.from; + if (params.to) query.to = params.to; + if (params.cpm !== undefined && params.cpm !== null && params.cpm !== '') query.cpm = params.cpm; + const { data } = await apiClient.get(`/api/advertising/campaigns/${id}/audience-size`, { params: query }); + return data; +} + +/** + * Один слот баннера кампании — ровно один из 15 канонических размеров + * (BannerSizes::all()). Клиент грузит СВОЙ готовый файл на КАЖДЫЙ размер — + * автогенерации из одной картинки больше нет. `slots` в GET .../banners + * всегда содержит все 15 размеров (загруженные и пустые вперемешку). + */ +export interface BannerSlot { + width: number; + height: number; + uploaded: boolean; + banner_id: number | null; + bytes: number | null; + included: boolean; + preview_url: string | null; +} + +/** Ответ GET /api/advertising/campaigns/{id}/banners. */ +export interface BannerSet { + approved_at: string | null; + max_bytes: number; + formats: string[]; + slots: BannerSlot[]; +} + +/** GET набор слотов баннеров кампании (все 15 размеров + момент утверждения). */ +export async function fetchBanners(id: number): Promise { + const { data } = await apiClient.get(`/api/advertising/campaigns/${id}/banners`); + return data; +} + +/** POST /api/advertising/campaigns/{id}/banners — загрузить готовый файл на конкретный размер (multipart width/height/file). */ +export async function uploadBanner(id: number, width: number, height: number, file: File): Promise<{ slot: BannerSlot }> { + await ensureCsrfCookie(); + const form = new FormData(); + form.append('width', String(width)); + form.append('height', String(height)); + form.append('file', file); + const { data } = await apiClient.post<{ slot: BannerSlot }>(`/api/advertising/campaigns/${id}/banners`, form); + return data; +} + +/** PATCH /api/advertising/campaigns/{id}/banners/{bannerId} — включить/выключить размер из показа. */ +export async function toggleBannerIncluded(id: number, bannerId: number, included: boolean): Promise<{ slot: BannerSlot }> { + await ensureCsrfCookie(); + const { data } = await apiClient.patch<{ slot: BannerSlot }>(`/api/advertising/campaigns/${id}/banners/${bannerId}`, { included }); + return data; +} + +/** DELETE /api/advertising/campaigns/{id}/banners/{bannerId} — удалить загруженный баннер размера. */ +export async function deleteBanner(id: number, bannerId: number): Promise { + await ensureCsrfCookie(); + await apiClient.delete(`/api/advertising/campaigns/${id}/banners/${bannerId}`); +} + +/** POST утвердить набор баннеров. */ +export async function approveBanners(id: number): Promise<{ approved_at: string | null }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ approved_at: string | null }>(`/api/advertising/campaigns/${id}/banners/approve`); + return data; +} + +/** POST /api/advertising/campaigns/{id}/launch — запустить кампанию (модерация Яндекса). */ +export async function launchCampaign(id: number): Promise<{ status: CampaignStatus }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/launch`); + return data; +} + +/** + * POST /api/advertising/campaigns/{id}/submit — отправить готовую кампанию «за показы» на запуск. + * Директа сейчас нет (заявка на доступ на рассмотрении, Ч.5b) — переводит кампанию в статус + * `queued` («готова к запуску, ждёт оператора»), реальный запуск в Директ — Часть 4. + */ +export async function submitCampaign(id: number): Promise<{ status: CampaignStatus }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/submit`); + return data; +} + +/** POST /api/advertising/campaigns/{id}/pause — поставить кампанию на паузу. */ +export async function pauseCampaign(id: number): Promise<{ status: CampaignStatus }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/pause`); + return data; +} + +/** POST /api/advertising/campaigns/{id}/resume — возобновить кампанию с паузы. */ +export async function resumeCampaign(id: number): Promise<{ status: CampaignStatus }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/resume`); + return data; +} + +/** + * POST /api/advertising/campaigns/{id}/revive — вернуть отклонённую кампанию в черновик, + * чтобы клиент переделал картинки и нажал обычное «Запустить». Второго пути запуска нет. + */ +export async function reviveCampaign(id: number): Promise<{ status: CampaignStatus }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/revive`); + return data; +} + +/** POST /api/advertising/campaigns/{id}/ads — добавить объявление (текстовый креатив). */ +export async function addCreative( + id: number, + payload: { title: string; text: string; href: string; title2?: string }, +): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post(`/api/advertising/campaigns/${id}/ads`, payload); + return data; +} + +/** POST /api/advertising/campaigns/{id}/ads/{adId}/image — загрузить картинку объявления. */ +export async function uploadCreativeImage(id: number, adId: number, file: File): Promise<{ hash: string }> { + await ensureCsrfCookie(); + const form = new FormData(); + form.append('file', file); + const { data } = await apiClient.post<{ hash: string }>(`/api/advertising/campaigns/${id}/ads/${adId}/image`, form); + return data; +} + +/** DELETE /api/advertising/campaigns/{id} — удалить ЧЕРНОВИК кампании своего тенанта (409 если не draft, 404 чужой). */ +export async function deleteCampaign(id: number): Promise { + await ensureCsrfCookie(); + await apiClient.delete(`/api/advertising/campaigns/${id}`); +} + +/** Ответ POST /api/advertising/campaigns/{id}/phones — сколько номеров распознано / отброшено. */ +export interface UploadPhonesResult { + recognized: number; + skipped: number; +} + +/** + * POST /api/advertising/campaigns/{id}/phones — загрузить «мой список номеров» кампании + * (файл csv/txt и/или текст, оба необязательны по отдельности — но хотя бы один нужен серверу). + */ +export async function uploadCampaignPhones( + id: number, + payload: { file?: File | null; text?: string }, +): Promise { + await ensureCsrfCookie(); + const form = new FormData(); + if (payload.file) form.append('file', payload.file); + if (payload.text && payload.text.trim() !== '') form.append('text', payload.text); + const { data } = await apiClient.post(`/api/advertising/campaigns/${id}/phones`, form); + return data; +} + +/** + * Счёт на пополнение РЕКЛАМНОГО кошелька (POST /api/billing/invoices, credit_target=advertising). + * Тот же эндпоинт, что и обычный счёт за лиды (api/billing.ts::createInvoice) — + * InvoicePaymentService зачисляет ad_wallets вместо tenants.balance_rub (см. + * app/Http/Controllers/Api/InvoiceController.php::store, min:100/max:1000000). + */ +export interface AdvertisingInvoice { + id: number; + invoice_number: string; + amount_total: string; + pdf_url: string; +} + +/** POST /api/billing/invoices с credit_target='advertising' — счёт по реквизитам для пополнения рекламного кошелька. */ +export async function createAdvertisingInvoice(amountRub: number): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ invoice: AdvertisingInvoice }>('/api/billing/invoices', { + amount_rub: amountRub, + credit_target: 'advertising', + }); + return data.invoice; +} + +/** + * Результат POST /api/billing/topup с credit_target='advertising' — две формы (зеркалит + * api/billing.ts::TopupResult / BillingController::topup): + * • реальный шлюз (флаг billing_yookassa_enabled ВКЛ): confirmation_url — редирект на оплату + * ЮKassa, кошелёк зачислится позже по webhook; + * • заглушка (флаг ВЫКЛ): ok:true — AdWalletService зачисляет рекламный кошелёк мгновенно. + */ +export interface AdvertisingCardTopupResult { + confirmation_url?: string; + ok?: boolean; +} + +/** POST /api/billing/topup с credit_target='advertising' — оплата картой рекламного кошелька. */ +export async function topupAdvertisingByCard(amountRub: string | number): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/billing/topup', { + amount_rub: amountRub, + credit_target: 'advertising', + }); + return data; +} + +/** Сообщение ленты кампании — окно передачи между Яндексом и клиентом. */ +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 — ответ клиента, можно с файлом. + * Мутация, поэтому сначала CSRF-cookie — как у всех POST'ов этого модуля. + */ +export async function sendCampaignMessage(id: number, body: string, file: File | null): Promise { + await ensureCsrfCookie(); + 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); +} diff --git a/app/resources/js/components/admin/TenantBalanceDialog.vue b/app/resources/js/components/admin/TenantBalanceDialog.vue index cc21719b..d05ed0d7 100644 --- a/app/resources/js/components/admin/TenantBalanceDialog.vue +++ b/app/resources/js/components/admin/TenantBalanceDialog.vue @@ -109,7 +109,7 @@ function close(): void { было {{ currentBalanceRub.toFixed(2) }} ₽ → станет {{ targetNormalized }} ₽ ( {{ Number(delta) > 0 ? '+' : '' }}{{ delta }} ₽ ) diff --git a/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue b/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue index 5637d027..180d510b 100644 --- a/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue +++ b/app/resources/js/components/admin/tenant-detail/TenantDetailTabs.vue @@ -59,7 +59,7 @@ const emit = defineEmits<{ {{ tx.description }} {{ formatRub(tx.amount) }} diff --git a/app/resources/js/components/admin/tenants/TenantsStatsHeader.vue b/app/resources/js/components/admin/tenants/TenantsStatsHeader.vue index ea5fff86..b8528f43 100644 --- a/app/resources/js/components/admin/tenants/TenantsStatsHeader.vue +++ b/app/resources/js/components/admin/tenants/TenantsStatsHeader.vue @@ -39,7 +39,7 @@ function formatRub(v: number): string { > · {{ stats.overdue }} просрочка{{ stats.overdue }} просрочка · diff --git a/app/resources/js/components/advertising/AdWalletHeader.vue b/app/resources/js/components/advertising/AdWalletHeader.vue new file mode 100644 index 00000000..75dbf818 --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletHeader.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/app/resources/js/components/advertising/AdWalletTopupDialog.vue b/app/resources/js/components/advertising/AdWalletTopupDialog.vue new file mode 100644 index 00000000..dadbb64e --- /dev/null +++ b/app/resources/js/components/advertising/AdWalletTopupDialog.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/app/resources/js/components/advertising/CampaignList.vue b/app/resources/js/components/advertising/CampaignList.vue new file mode 100644 index 00000000..eb71a1b9 --- /dev/null +++ b/app/resources/js/components/advertising/CampaignList.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/app/resources/js/components/advertising/CampaignMessages.vue b/app/resources/js/components/advertising/CampaignMessages.vue new file mode 100644 index 00000000..d3f183da --- /dev/null +++ b/app/resources/js/components/advertising/CampaignMessages.vue @@ -0,0 +1,104 @@ + + + diff --git a/app/resources/js/components/advertising/CampaignReportDialog.vue b/app/resources/js/components/advertising/CampaignReportDialog.vue new file mode 100644 index 00000000..4c3b18a7 --- /dev/null +++ b/app/resources/js/components/advertising/CampaignReportDialog.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue new file mode 100644 index 00000000..f5553882 --- /dev/null +++ b/app/resources/js/components/advertising/CampaignWizard.vue @@ -0,0 +1,1133 @@ + + + + + diff --git a/app/resources/js/components/advertising/CreativeForm.vue b/app/resources/js/components/advertising/CreativeForm.vue new file mode 100644 index 00000000..ec4d9ca6 --- /dev/null +++ b/app/resources/js/components/advertising/CreativeForm.vue @@ -0,0 +1,233 @@ + + + diff --git a/app/resources/js/components/layout/AppMoreDrawer.vue b/app/resources/js/components/layout/AppMoreDrawer.vue index 693d0744..993e5639 100644 --- a/app/resources/js/components/layout/AppMoreDrawer.vue +++ b/app/resources/js/components/layout/AppMoreDrawer.vue @@ -31,7 +31,7 @@ function openAdStub(): void { adStubOpen.value = true; } -// Канал с route (сейчас — «Реклама Телеграм») ведёт на реальный экран через :to +// Канал с route (сейчас — Яндекс Аудитория и Реклама Телеграм) ведёт на реальный экран через :to // (навигация закрывает «Ещё»); остальные каналы — прежняя заглушка openAdStub. function onChannelClick(c: AdChannel): void { if (c.route) { @@ -67,6 +67,14 @@ function onChannelClick(c: AdChannel): void { :data-testid="c.testid" @click="onChannelClick(c)" /> + + diff --git a/app/resources/js/components/layout/AppSidebar.vue b/app/resources/js/components/layout/AppSidebar.vue index 7a91b481..41b55968 100644 --- a/app/resources/js/components/layout/AppSidebar.vue +++ b/app/resources/js/components/layout/AppSidebar.vue @@ -62,7 +62,9 @@ const navGroups = computed(() => [ eyebrow: 'Работа', items: [ { title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' }, - ...(autopodbor.enabled ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []), + ...(autopodbor.enabled + ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] + : []), // B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined), // resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен. { @@ -78,13 +80,18 @@ const navGroups = computed(() => [ }, { eyebrow: 'Рекламные возможности', - // Канал с route (сейчас — «Реклама Телеграм») ведёт на реальный экран - // (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub. - items: AD_CHANNELS.map((c) => - c.route - ? { title: c.title, icon: c.icon, to: c.route, testid: c.testid } - : { title: c.title, icon: c.icon, stub: true, testid: c.testid }, - ), + // Канал с route (сейчас — Яндекс Аудитория и Реклама Телеграм) ведёт на реальный + // экран (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub. + // T19: «Рекламный кошелёк» — не канал (в AD_CHANNELS не входит), отдельный + // пункт-ссылка на /advertising/wallet. Число баланса в меню не показываем. + items: [ + ...AD_CHANNELS.map((c) => + c.route + ? { title: c.title, icon: c.icon, to: c.route, testid: c.testid } + : { title: c.title, icon: c.icon, stub: true, testid: c.testid }, + ), + { title: 'Рекламный кошелёк', icon: 'mdi-wallet-outline', to: '/advertising/wallet', testid: 'ad-nav-wallet' }, + ], }, { eyebrow: 'Финансы', diff --git a/app/resources/js/components/layout/advertisingChannels.ts b/app/resources/js/components/layout/advertisingChannels.ts index 26110786..63fe3629 100644 --- a/app/resources/js/components/layout/advertisingChannels.ts +++ b/app/resources/js/components/layout/advertisingChannels.ts @@ -18,7 +18,12 @@ export const AD_RELEASE_DATE = '01.09.2026'; export const AD_CHANNELS: readonly AdChannel[] = [ { title: 'ИИ колцентр', icon: 'mdi-robot-outline', testid: 'ad-nav-ai-callcenter' }, { title: 'Рассылка СМС', icon: 'mdi-message-text', testid: 'ad-nav-sms' }, - { title: 'Яндекс Аудитория', icon: 'mdi-target', testid: 'ad-nav-yandex-audience' }, + { + title: 'Яндекс Аудитория', + icon: 'mdi-target', + testid: 'ad-nav-yandex-audience', + route: '/advertising/yandex', + }, { title: 'VK Реклама', icon: 'mdi-bullhorn-outline', testid: 'ad-nav-vk' }, { title: 'Реклама Телеграм', icon: 'mdi-send-outline', testid: 'ad-nav-telegram', route: '/advertising/telegram' }, ]; diff --git a/app/resources/js/components/projects/DaysBulkDialog.vue b/app/resources/js/components/projects/DaysBulkDialog.vue index 57ca21ef..2fe1736f 100644 --- a/app/resources/js/components/projects/DaysBulkDialog.vue +++ b/app/resources/js/components/projects/DaysBulkDialog.vue @@ -4,7 +4,7 @@ Дни сбора лидов — для {{ count }} проектов
-
➕ Добавить дни
+
➕ Добавить дни
-
➕ Добавить регионы
+
➕ Добавить регионы
+import { computed } from 'vue'; + +const props = defineProps<{ channels: Array<'yandex' | 'vk' | 'mts' | 'sms'> }>(); + +const META: Record = { + yandex: { icon: 'mdi-alpha-y-circle', label: 'Яндекс', color: '#ff3333' }, + vk: { icon: 'mdi-alpha-v-circle', label: 'ВКонтакте', color: '#0077ff' }, + mts: { icon: 'mdi-send-circle', label: 'Телеграм', color: '#0088cc' }, + sms: { icon: 'mdi-message-text', label: 'СМС', color: '#0f6e56' }, +}; + +const items = computed(() => props.channels.map((c) => ({ key: c, ...META[c] }))); + + + diff --git a/app/resources/js/layouts/AdminLayout.vue b/app/resources/js/layouts/AdminLayout.vue index 0d807ed2..379d2469 100644 --- a/app/resources/js/layouts/AdminLayout.vue +++ b/app/resources/js/layouts/AdminLayout.vue @@ -30,6 +30,7 @@ const navItems: NavItem[] = [ { title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' }, { title: 'Посетители', icon: 'mdi-account-eye-outline', to: '/admin/visitors' }, { title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' }, + { title: 'Реклама', icon: 'mdi-bullhorn-outline', to: '/admin/advertising' }, { title: 'Счета', icon: 'mdi-file-document-outline', to: '/admin/invoices' }, { title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' }, { title: 'Тарифы «Конкурентного поля»', icon: 'mdi-bullseye-arrow', to: '/admin/autopodbor-pricing' }, diff --git a/app/resources/js/lib/advertising/creativeLimits.ts b/app/resources/js/lib/advertising/creativeLimits.ts new file mode 100644 index 00000000..bd5a2d53 --- /dev/null +++ b/app/resources/js/lib/advertising/creativeLimits.ts @@ -0,0 +1,89 @@ +/** + * Клиентские проверки лимитов креатива объявления Яндекс.Директа. + * Порт 1:1 логики app/app/Services/Advertising/CreativeValidator.php — цифры и + * сообщения об ошибках менять только синхронно с бэком (единый источник — PHP-класс). + */ + +/** «Узкие» символы — не считаются в общий лимит символов (до 15 штук сверху). */ +export const NARROW_CHARS = ['!', ',', '.', ';', ':', '"'] as const; + +const TITLE_MAX_LENGTH = 56; +const TITLE_MAX_WORD_LENGTH = 22; + +const TITLE2_MAX_NORMAL = 30; +const TITLE2_MAX_NARROW = 15; + +const TEXT_MAX_NORMAL = 81; +const TEXT_MAX_NARROW = 15; +const TEXT_MAX_WORD_LENGTH = 23; + +/** Разбивает строку на слова по пробелам, отбрасывая пустые. */ +function words(value: string): string[] { + return value + .trim() + .split(/\s+/u) + .filter((w) => w !== ''); +} + +/** Считает отдельно количество обычных и «узких» символов в строке. */ +export function countNormalAndNarrow(value: string): { normal: number; narrow: number } { + let normal = 0; + let narrow = 0; + + for (const char of Array.from(value)) { + if ((NARROW_CHARS as readonly string[]).includes(char)) { + narrow++; + } else { + normal++; + } + } + + return { normal, narrow }; +} + +/** Проверка заголовка: ≤56 символов, любое слово ≤22. */ +export function validateTitle(title: string): string | null { + if (Array.from(title).length > TITLE_MAX_LENGTH) { + return `Заголовок длиннее ${TITLE_MAX_LENGTH} символов`; + } + + for (const word of words(title)) { + if (Array.from(word).length > TITLE_MAX_WORD_LENGTH) { + return `В заголовке есть слово длиннее ${TITLE_MAX_WORD_LENGTH} символов`; + } + } + + return null; +} + +/** + * Проверка второго заголовка: ≤30 обычных символов + до 15 узких (считаются раздельно). + */ +export function validateTitle2(title2: string): string | null { + const { normal, narrow } = countNormalAndNarrow(title2); + + if (normal > TITLE2_MAX_NORMAL || narrow > TITLE2_MAX_NARROW) { + return `Второй заголовок длиннее нормы (${TITLE2_MAX_NORMAL} обычных символов + до ${TITLE2_MAX_NARROW} узких)`; + } + + return null; +} + +/** + * Проверка текста: ≤81 обычный символ + до 15 узких (раздельно); любое слово ≤23. + */ +export function validateText(text: string): string | null { + const { normal, narrow } = countNormalAndNarrow(text); + + if (normal > TEXT_MAX_NORMAL || narrow > TEXT_MAX_NARROW) { + return `Текст длиннее нормы (${TEXT_MAX_NORMAL} обычных символов + до ${TEXT_MAX_NARROW} узких)`; + } + + for (const word of words(text)) { + if (Array.from(word).length > TEXT_MAX_WORD_LENGTH) { + return `В тексте есть слово длиннее ${TEXT_MAX_WORD_LENGTH} символов`; + } + } + + return null; +} diff --git a/app/resources/js/lib/advertising/humanizeCreativeErrors.ts b/app/resources/js/lib/advertising/humanizeCreativeErrors.ts new file mode 100644 index 00000000..ce488237 --- /dev/null +++ b/app/resources/js/lib/advertising/humanizeCreativeErrors.ts @@ -0,0 +1,22 @@ +/** + * Очеловечивание сообщений 422 от B1 CreativeValidator для формы креатива. + * Сервер пишет технические имена полей движка (title/title2/text/href/image) + * в формах «Поле …» / «… в поле …» — заменяем их на русские + * подписи полей формы, остальной текст сообщения не трогаем. + */ + +const FIELD_LABELS: Record = { + title: 'Заголовок', + title2: 'Второй заголовок', + text: 'Текст объявления', + href: 'Ссылка на сайт', + image: 'Картинка', +}; + +/** Заменяет техническое имя поля после «Поле »/«поле » на закавыченную русскую подпись. */ +export function humanizeMessage(msg: string): string { + return msg.replace(/(Поле|поле)\s+([a-zA-Z][a-zA-Z0-9_]*)/g, (match, poleWord: string, field: string) => { + const label = FIELD_LABELS[field]; + return label ? `${poleWord} «${label}»` : match; + }); +} diff --git a/app/resources/js/lib/advertising/imageMeta.ts b/app/resources/js/lib/advertising/imageMeta.ts new file mode 100644 index 00000000..2e6a1755 --- /dev/null +++ b/app/resources/js/lib/advertising/imageMeta.ts @@ -0,0 +1,31 @@ +import type { ImageMeta } from './imageRule'; + +/** + * Читает метаданные картинки (ширина/высота/вес/MIME) из выбранного файла через DOM Image. + * Не покрыт юнит-тестом (требует DOM Image/URL.createObjectURL) — проверяется вручную/e2e; + * чистая логика проверки лимитов покрыта тестами imageRule.spec.ts (checkImage). + */ +export function readImageMeta(file: File): Promise { + return new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const image = new Image(); + + image.onload = () => { + const meta: ImageMeta = { + width: image.naturalWidth, + height: image.naturalHeight, + bytes: file.size, + mime: file.type, + }; + URL.revokeObjectURL(objectUrl); + resolve(meta); + }; + + image.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error('Не удалось прочитать изображение')); + }; + + image.src = objectUrl; + }); +} diff --git a/app/resources/js/lib/advertising/imageRule.ts b/app/resources/js/lib/advertising/imageRule.ts new file mode 100644 index 00000000..29fa2b1e --- /dev/null +++ b/app/resources/js/lib/advertising/imageRule.ts @@ -0,0 +1,33 @@ +/** + * Клиентская проверка изображения-креатива по нормативам Яндекс.Директа. + * Порт 1:1 логики CreativeValidator::validateImage (app/app/Services/Advertising/CreativeValidator.php). + * Порядок проверок как на бэке: сначала формат, потом вес, потом размер стороны. + */ + +const IMAGE_MIN_SIDE = 450; +const IMAGE_MAX_BYTES = 10 * 1024 * 1024; // 10 МБ +const IMAGE_ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/gif']; + +export interface ImageMeta { + width: number; + height: number; + bytes: number; + mime: string; +} + +/** Возвращает текст ошибки по-русски или null, если картинка проходит проверку. */ +export function checkImage(meta: ImageMeta): string | null { + if (!IMAGE_ALLOWED_MIME.includes(meta.mime)) { + return `Недопустимый формат картинки: ${meta.mime}`; + } + + if (meta.bytes > IMAGE_MAX_BYTES) { + return `Картинка тяжелее ${Math.floor(IMAGE_MAX_BYTES / 1024 / 1024)} МБ`; + } + + if (meta.width < IMAGE_MIN_SIDE || meta.height < IMAGE_MIN_SIDE) { + return `Картинка меньше минимума ${IMAGE_MIN_SIDE}×${IMAGE_MIN_SIDE} px`; + } + + return null; +} diff --git a/app/resources/js/lib/advertising/urlRule.ts b/app/resources/js/lib/advertising/urlRule.ts new file mode 100644 index 00000000..92180992 --- /dev/null +++ b/app/resources/js/lib/advertising/urlRule.ts @@ -0,0 +1,12 @@ +/** + * Проверка, что строка — валидная http/https ссылка. + * Используется в форме креатива Яндекс.Директа (поле «Ссылка на сайт»). + */ +export function isValidHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} diff --git a/app/resources/js/plugins/vuetify.ts b/app/resources/js/plugins/vuetify.ts index 407de1c0..c256ff8a 100644 --- a/app/resources/js/plugins/vuetify.ts +++ b/app/resources/js/plugins/vuetify.ts @@ -121,6 +121,14 @@ const liderraForest: ThemeDefinition = { 'on-secondary': '#F6F3EC', success: '#2E8B57', warning: '#D9A441', + // Текстовые двойники success/warning. Сами брендовые цвета не трогаем — + // они остаются для заливок, иконок и рамок, где 4.5:1 не требуется. Но + // ТЕКСТОМ они нечитаемы: success даёт 4.25:1 на белой карточке и 3.83:1 + // на ивори #F6F3EC, warning — 2.25/2.03 при норме WCAG 2.1 AA 4.5:1. + // Затемнённые двойники: 6.10/5.51 и 5.37/4.85 — тон сохранён. + // Использовать как text-success-strong / text-warning-strong. + 'success-strong': '#256F46', + 'warning-strong': '#8A6410', error: '#B83A3A', info: '#3F7C95', // Расширения — для data viz и semantic uses diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index f4b5d82c..81899ce5 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -163,7 +163,13 @@ const routes: RouteRecordRaw[] = [ path: '/autopodbor', name: 'autopodbor', component: () => import('../views/autopodbor/AutopodborView.vue'), - meta: { layout: 'app', title: 'Конкурентное поле', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Конкурентное поле' }, + meta: { + layout: 'app', + title: 'Конкурентное поле', + requiresAuth: true, + transition: 'ld-route-fadeup', + devLabel: 'Конкурентное поле', + }, }, { path: '/billing', @@ -178,6 +184,30 @@ const routes: RouteRecordRaw[] = [ devLabel: 'Биллинг', }, }, + { + path: '/advertising/yandex', + name: 'advertising-yandex', + component: () => import('../views/advertising/AdvertisingYandexView.vue'), + meta: { + layout: 'app', + title: 'Яндекс Аудитория', + requiresAuth: true, + transition: 'ld-route-fadeup', + devLabel: 'Реклама · Яндекс', + }, + }, + { + path: '/advertising/wallet', + name: 'advertising-wallet', + component: () => import('../views/advertising/AdWalletView.vue'), + meta: { + layout: 'app', + title: 'Рекламный кошелёк', + requiresAuth: true, + transition: 'ld-route-fadeup', + devLabel: 'Реклама · Кошелёк', + }, + }, { path: '/settings', name: 'settings', @@ -253,6 +283,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/admin/AdminBillingView.vue'), meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' }, }, + { + path: '/admin/advertising', + name: 'admin-advertising', + component: () => import('../views/admin/AdminAdvertisingView.vue'), + meta: { layout: 'admin', title: 'Реклама — расход/маржа', requiresAuth: true, devLabel: 'Admin Advertising' }, + }, { path: '/admin/invoices', name: 'admin-invoices', diff --git a/app/resources/js/views/admin/AdminAdvertisingView.vue b/app/resources/js/views/admin/AdminAdvertisingView.vue new file mode 100644 index 00000000..e15de483 --- /dev/null +++ b/app/resources/js/views/admin/AdminAdvertisingView.vue @@ -0,0 +1,550 @@ + + + + + diff --git a/app/resources/js/views/admin/AdminBillingView.vue b/app/resources/js/views/admin/AdminBillingView.vue index e01464ba..682987b1 100644 --- a/app/resources/js/views/admin/AdminBillingView.vue +++ b/app/resources/js/views/admin/AdminBillingView.vue @@ -261,7 +261,7 @@ function tariffLabel(t: string): string {
Просрочка
-
+
{{ summary.overdue_count }}
diff --git a/app/resources/js/views/admin/AdminDashboardView.vue b/app/resources/js/views/admin/AdminDashboardView.vue index 07a55e76..6c3d0f70 100644 --- a/app/resources/js/views/admin/AdminDashboardView.vue +++ b/app/resources/js/views/admin/AdminDashboardView.vue @@ -558,7 +558,7 @@ defineExpose({
Новых за период - +{{ summary?.finance.new_clients ?? 0 }} + +{{ summary?.finance.new_clients ?? 0 }}
Открыть финансы →
@@ -777,7 +777,7 @@ defineExpose({
Новых за период - +{{ summary?.clients.new_count ?? 0 }} + +{{ summary?.clients.new_count ?? 0 }}
Заходили за период @@ -787,7 +787,7 @@ defineExpose({ Спят (не заходят) {{ summary?.clients.dormant ?? '—' }}
Открыть клиентов →
@@ -821,7 +821,7 @@ defineExpose({
Чистый приток
-
{{ rub(finance?.kpi.net_inflow_rub) }}
+
{{ rub(finance?.kpi.net_inflow_rub) }}
@@ -1174,7 +1174,7 @@ variant="text" size="small" class="text-none" color="primary"
Всего активных
{{ clients?.kpi.total_active ?? 0 }}
-
Новых
+{{ clients?.kpi.new_count ?? 0 }}
+
Новых
+{{ clients?.kpi.new_count ?? 0 }}
Заходили
{{ clients?.kpi.logged_in ?? 0 }}
Получали лиды
{{ clients?.kpi.got_leads ?? 0 }}
Платили
{{ clients?.kpi.paid ?? 0 }}
@@ -1190,7 +1190,7 @@ variant="text" size="small" class="text-none" color="primary" {{ c.organization_name }} - {{ loginLabel(c.last_login_at) }} + {{ loginLabel(c.last_login_at) }} {{ c.delivered_in_month }} {{ rub(c.balance_rub) }} @@ -1209,7 +1209,7 @@ variant="text" size="small" class="text-none" color="primary" {{ c.organization_name }} - {{ loginLabel(c.last_login_at) }} + {{ loginLabel(c.last_login_at) }} {{ rub(c.balance_rub) }} diff --git a/app/resources/js/views/admin/AdminIncidentsView.vue b/app/resources/js/views/admin/AdminIncidentsView.vue index 6469755d..4830b68c 100644 --- a/app/resources/js/views/admin/AdminIncidentsView.vue +++ b/app/resources/js/views/admin/AdminIncidentsView.vue @@ -167,7 +167,7 @@ function formatDate(iso: string): string {
Расследуется
-
{{ stats.investigating }}
+
{{ stats.investigating }}
diff --git a/app/resources/js/views/admin/AdminSupplierIntegrationView.vue b/app/resources/js/views/admin/AdminSupplierIntegrationView.vue index 5d72f99f..59ee3831 100644 --- a/app/resources/js/views/admin/AdminSupplierIntegrationView.vue +++ b/app/resources/js/views/admin/AdminSupplierIntegrationView.vue @@ -372,7 +372,7 @@ onMounted(() => { {{ run.groups_total }} {{ run.synced_ok }} - + {{ run.manual_queued }} diff --git a/app/resources/js/views/admin/AdminSystemView.vue b/app/resources/js/views/admin/AdminSystemView.vue index 0ff873a9..3a19c9bf 100644 --- a/app/resources/js/views/admin/AdminSystemView.vue +++ b/app/resources/js/views/admin/AdminSystemView.vue @@ -148,7 +148,7 @@ defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated,
Текущий баланс: - {{ y360Status.balance }} ₽ + {{ y360Status.balance }} ₽ не задан · обновлён {{ formatDate(y360Status.updated_at) }} diff --git a/app/resources/js/views/advertising/AdWalletView.vue b/app/resources/js/views/advertising/AdWalletView.vue new file mode 100644 index 00000000..018cd5f5 --- /dev/null +++ b/app/resources/js/views/advertising/AdWalletView.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/app/resources/js/views/advertising/AdvertisingYandexView.vue b/app/resources/js/views/advertising/AdvertisingYandexView.vue new file mode 100644 index 00000000..2f5a385a --- /dev/null +++ b/app/resources/js/views/advertising/AdvertisingYandexView.vue @@ -0,0 +1,97 @@ + + + diff --git a/app/resources/js/views/settings/RequisitesTab.vue b/app/resources/js/views/settings/RequisitesTab.vue index 4f596c2b..a1902a25 100644 --- a/app/resources/js/views/settings/RequisitesTab.vue +++ b/app/resources/js/views/settings/RequisitesTab.vue @@ -254,7 +254,7 @@ async function save(): Promise {
{{ lookupMessage }} diff --git a/app/resources/views/emails/ad_wallet_low_balance.blade.php b/app/resources/views/emails/ad_wallet_low_balance.blade.php new file mode 100644 index 00000000..0a3dc0fc --- /dev/null +++ b/app/resources/views/emails/ad_wallet_low_balance.blade.php @@ -0,0 +1,14 @@ + + +Реклама остановлена + +

Здравствуйте, {{ $tenant->organization_name }}!

+

Реклама на кандидатов приостановлена — на рекламном кошельке не хватает средств покрыть заморозки по текущим кампаниям.

+
    +
  • Баланс рекламного кошелька: {{ number_format((float) $balanceRub, 2, ',', ' ') }} ₽
  • +
  • Заморожено под активные кампании: {{ number_format((float) $frozenRub, 2, ',', ' ') }} ₽
  • +
+

Пополните рекламный кошелёк на странице «Рекламные возможности» — и реклама возобновится автоматически.

+

С уважением, команда Лидерра.

+ + diff --git a/app/resources/views/mail/ad-document-attached.blade.php b/app/resources/views/mail/ad-document-attached.blade.php new file mode 100644 index 00000000..4f207cf1 --- /dev/null +++ b/app/resources/views/mail/ad-document-attached.blade.php @@ -0,0 +1,22 @@ +

Клиент приложил документ по рекламной кампании «{{ $campaignName }}».

+ +
    +
  • Кампания: № {{ $campaignId }}
  • +
  • Клиент: № {{ $tenantId }}
  • +
  • Файл: {{ $fileName }}
  • +
+ +@if ($comment !== '') +

Клиент написал:

+
+ {{ $comment }} +
+@endif + +

+ Яндекс не принимает документы от программы — отнести файл придётся руками: + чат поддержки в кабинете Директа либо форма обратной связи модерации. + Сам документ лежит в портале, в переписке по кампании. +

+ +

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

diff --git a/app/resources/views/mail/ad-moderation-message.blade.php b/app/resources/views/mail/ad-moderation-message.blade.php new file mode 100644 index 00000000..6bba4808 --- /dev/null +++ b/app/resources/views/mail/ad-moderation-message.blade.php @@ -0,0 +1,9 @@ +

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

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

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

+ +

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

diff --git a/app/routes/console.php b/app/routes/console.php index ed80c9b9..2e9e7681 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -1,5 +1,6 @@ everyFifteenMinutes() ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\PollTelegramModerationJob', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\PollTelegramModerationJob', false, 'Job failed', null)); +// Рекламный модуль Директа (Часть B1). Джобы сами перечисляют кампании через +// pgsql_supplier (BYPASSRLS) — на дефолтной роли crm_app_user очередь без tenant- +// контекста RLS дала бы 0 строк. Времена МСК, вне пиковых часов и не пересекаются +// с уже занятыми слотами (03:20 SyncAd, 03:30 SyncVk, 04:00 audit). +Schedule::job(new SyncCampaignAudienceJob) + ->dailyAt('03:50') + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', false, 'Job failed', null)); + +Schedule::job(new ChargeCampaignSpendJob) + ->dailyAt('04:20') + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', false, 'Job failed', null)); + +Schedule::job(new SyncCampaignModerationJob) + ->everyTwoHours() + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', false, 'Job failed', null)); + +// Сторож очереди робота-грузчика креативов. Задание, брошенное «в работе», держит очередь +// для ВСЕХ клиентов: пока хоть одно в работе, выдача отвечает «работы нет» и ни одна +// кампания не стартует. Робот может умереть молча — разбирать пробку руками некому. +// Каждые 10 минут при пороге в 30 минут: свежее задание сторож не отбирает. +Schedule::command('creative-jobs:reap') + ->everyTenMinutes() + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('creative-jobs:reap', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('creative-jobs:reap', false, 'Command failed', null)); diff --git a/app/routes/web.php b/app/routes/web.php index 0d3dcb44..92fcadb7 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -1,5 +1,6 @@ group(function () { // SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц. Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index'); + // T4a: SaaS-admin → Реклама: расход у Яндекса и наша маржа по тенантам (ad_wallet_transactions). + Route::get('/api/admin/advertising/spend', 'App\Http\Controllers\Api\AdminAdvertisingController@spend'); + Route::get('/api/admin/advertising/settings', 'App\Http\Controllers\Api\AdminAdvertisingController@settings'); + Route::put('/api/admin/advertising/settings', 'App\Http\Controllers\Api\AdminAdvertisingController@updateSettings'); + // 8e: кампании, ждущие номер креатива Яндекса, перед запуском. + Route::get('/api/admin/advertising/campaigns', 'App\Http\Controllers\Api\AdminAdvertisingController@campaignsAwaiting'); + Route::patch('/api/admin/advertising/campaigns/{id}/creative', 'App\Http\Controllers\Api\AdminAdvertisingController@setCampaignCreative') + ->whereNumber('id'); + // Задача 15: «ждёт разбора» — кампании, где робот сходил в кабинет и не понял, что видит. + Route::get('/api/admin/advertising/robot-stuck', 'App\Http\Controllers\Api\AdminAdvertisingController@robotStuck'); + // SaaS-admin → Счета: список выставленных счетов + ручная отметка оплаты (Этап 1). Route::get('/api/admin/invoices', 'App\Http\Controllers\Api\AdminInvoiceController@index'); Route::post('/api/admin/invoices/{id}/mark-paid', 'App\Http\Controllers\Api\AdminInvoiceController@markPaid') @@ -374,6 +386,22 @@ Route::middleware(['admin-db', 'sales-integration'])->prefix('api/sales/integrat Route::post('/warming-state', [SalesIntegrationController::class, 'warmingState']); }); +// Служебный канал «Робот-грузчик креативов → Портал». Токен вместо пользователя. +// admin-db — робот ходит без tenant-контекста, работает под ролью crm_admin_user. +// Токен ПЕРВЫМ, служебное соединение вторым: сначала пропуск, потом ключи от служебного +// входа. Тот же порядок, что у админского канала (см. комментарий в UseAdminConnection). +Route::middleware(['creative-robot', 'admin-db'])->prefix('api/creative-robot')->group(function () { + Route::get('/next', [CreativeRobotController::class, 'next']); + // Номер задания — часть адреса файла: робот получает файлы только того задания, + // которое ему выдали, а не «какого-нибудь, что сейчас в работе». + Route::get('/jobs/{jobId}/banners/{bannerId}/file', [CreativeRobotController::class, 'file']) + ->whereNumber(['jobId', 'bannerId']); + Route::post('/jobs/{jobId}/done', [CreativeRobotController::class, 'done'])->whereNumber('jobId'); + // Доклад разведки: что робот прочитал на экране кабинета про отклонённое объявление. + // Отдельно от «готово» — у разведки другой смысл готовности: без текста её не бывает. + Route::post('/jobs/{jobId}/inspection', [CreativeRobotController::class, 'inspection'])->whereNumber('jobId'); +}); + // Plan 4 Task 11: tenant charges ledger (read-only + CSV export). // RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant // видит только свои lead_charges. Pagination 20/page, фильтры period/source. @@ -398,6 +426,43 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(fun Route::get('/invoices/{id}/act', 'App\Http\Controllers\Api\InvoiceController@act')->whereNumber('id'); }); +// Реклама тенанта: статус рекламного кошелька для баннера «пополните +// рекламный кошелёк» (Task 9, часть B). RLS на ad_wallets требует tenant middleware. +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/advertising')->group(function () { + Route::get('/wallet', 'App\Http\Controllers\Api\AdvertisingWalletController@show'); + + // Кампании Директа для клиентского портала (Часть A, Task 12) — CRUD-черновик + + // счётчик аудитории + запуск + креатив. Фронт (Часть B2) — отдельная задача. + Route::get('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@index'); + Route::post('/campaigns', 'App\Http\Controllers\Api\AdvertisingCampaignController@store'); + Route::get('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@show')->whereNumber('id'); + Route::patch('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@update')->whereNumber('id'); + Route::get('/campaigns/{id}/audience-size', 'App\Http\Controllers\Api\AdvertisingCampaignController@audienceSize')->whereNumber('id'); + Route::get('/campaigns/{id}/banners', 'App\Http\Controllers\Api\AdvertisingCampaignController@listBanners')->whereNumber('id'); + Route::post('/campaigns/{id}/banners', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadBanner')->whereNumber('id'); + Route::patch('/campaigns/{id}/banners/{bannerId}', 'App\Http\Controllers\Api\AdvertisingCampaignController@toggleBannerIncluded')->whereNumber(['id', 'bannerId']); + Route::delete('/campaigns/{id}/banners/{bannerId}', 'App\Http\Controllers\Api\AdvertisingCampaignController@deleteBanner')->whereNumber(['id', 'bannerId']); + Route::get('/campaigns/{id}/banners/{bannerId}/preview', 'App\Http\Controllers\Api\AdvertisingCampaignController@previewBanner')->whereNumber('id')->whereNumber('bannerId'); + Route::post('/campaigns/{id}/banners/approve', 'App\Http\Controllers\Api\AdvertisingCampaignController@approveBanners')->whereNumber('id'); + Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\AdvertisingCampaignController@launch')->whereNumber('id'); + Route::post('/campaigns/{id}/submit', 'App\Http\Controllers\Api\AdvertisingCampaignController@submit')->whereNumber('id'); + // «Исправить» — вернуть отклонённую кампанию в черновик. Отдельного пути запуска + // за этим не появляется: дальше клиент жмёт обычное «Запустить». + Route::post('/campaigns/{id}/revive', 'App\Http\Controllers\Api\AdvertisingCampaignController@revive')->whereNumber('id'); + Route::post('/campaigns/{id}/pause', 'App\Http\Controllers\Api\AdvertisingCampaignController@pause')->whereNumber('id'); + Route::post('/campaigns/{id}/resume', 'App\Http\Controllers\Api\AdvertisingCampaignController@resume')->whereNumber('id'); + Route::post('/campaigns/{id}/ads', 'App\Http\Controllers\Api\AdvertisingCampaignController@storeAd')->whereNumber('id'); + Route::post('/campaigns/{id}/ads/{adId}/image', 'App\Http\Controllers\Api\AdvertisingCampaignController@uploadAdImage')->whereNumber(['id', 'adId']); + Route::delete('/campaigns/{id}', 'App\Http\Controllers\Api\AdvertisingCampaignController@destroy')->whereNumber('id'); + Route::post('/campaigns/{id}/phones', 'App\Http\Controllers\Api\AdvertisingCampaignController@storePhones')->whereNumber('id'); + + // Лента сообщений по кампании — окно передачи между Яндексом и клиентом: + // пояснения модератора, ответы клиента, служебные отметки портала. + Route::get('/campaigns/{id}/messages', 'App\Http\Controllers\Api\AdvertisingCampaignMessageController@index')->whereNumber('id'); + 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']); +}); + // API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware. Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () { Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index'); diff --git a/app/tests/Feature/Admin/AdminAdCampaignCreativeTest.php b/app/tests/Feature/Admin/AdminAdCampaignCreativeTest.php new file mode 100644 index 00000000..4932e0d5 --- /dev/null +++ b/app/tests/Feature/Admin/AdminAdCampaignCreativeTest.php @@ -0,0 +1,118 @@ +create(['organization_name' => 'Окна Москва']); + + $queued = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания в очереди', + 'status' => AdCampaign::STATUS_QUEUED, + 'audience_days' => 10, + 'budget_rub' => '500.00', + 'landing_url' => 'https://example.ru/landing', + 'estimated_impressions' => 12000, + ]); + + AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Черновик', + 'status' => AdCampaign::STATUS_DRAFT, + 'audience_days' => 10, + 'budget_rub' => '500.00', + ]); + + $r = $this->getJson('/api/admin/advertising/campaigns'); + $r->assertStatus(200); + + $data = $r->json('data'); + expect($data)->toHaveCount(1); + expect($data[0]['id'])->toBe($queued->id); + expect($data[0]['tenant_id'])->toBe($tenant->id); + expect($data[0]['tenant_name'])->toBe('Окна Москва'); + expect($data[0]['name'])->toBe('Кампания в очереди'); + expect($data[0]['status'])->toBe(AdCampaign::STATUS_QUEUED); + expect($data[0]['yandex_creative_id'])->toBeNull(); + expect($data[0]['landing_url'])->toBe('https://example.ru/landing'); + expect($data[0]['estimated_impressions'])->toBe(12000); +}); + +test('GET /api/admin/advertising/campaigns сортирует по id desc', function () { + $tenant = Tenant::factory()->create(); + + $first = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'A', 'status' => AdCampaign::STATUS_QUEUED, + 'audience_days' => 10, 'budget_rub' => '500.00', + ]); + $second = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'B', 'status' => AdCampaign::STATUS_QUEUED, + 'audience_days' => 10, 'budget_rub' => '500.00', + ]); + + $r = $this->getJson('/api/admin/advertising/campaigns'); + + expect($r->json('data.0.id'))->toBe($second->id); + expect($r->json('data.1.id'))->toBe($first->id); +}); + +test('PATCH .../creative с валидным номером сохраняет yandex_creative_id', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'X', 'status' => AdCampaign::STATUS_QUEUED, + 'audience_days' => 10, 'budget_rub' => '500.00', + ]); + + $r = $this->patchJson("/api/admin/advertising/campaigns/{$campaign->id}/creative", [ + 'yandex_creative_id' => 987654321, + ]); + + $r->assertStatus(200); + expect($r->json('id'))->toBe($campaign->id); + expect($r->json('yandex_creative_id'))->toBe(987654321); + + expect(AdCampaign::find($campaign->id)->yandex_creative_id)->toBe(987654321); +}); + +test('PATCH .../creative с невалидным номером (0/строка/пусто) → 422', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'X', 'status' => AdCampaign::STATUS_QUEUED, + 'audience_days' => 10, 'budget_rub' => '500.00', + ]); + + $this->patchJson("/api/admin/advertising/campaigns/{$campaign->id}/creative", ['yandex_creative_id' => 0]) + ->assertStatus(422); + + $this->patchJson("/api/admin/advertising/campaigns/{$campaign->id}/creative", ['yandex_creative_id' => 'abc']) + ->assertStatus(422); + + $this->patchJson("/api/admin/advertising/campaigns/{$campaign->id}/creative", []) + ->assertStatus(422); +}); + +test('PATCH .../creative на несуществующий id → 404', function () { + $r = $this->patchJson('/api/admin/advertising/campaigns/999999999/creative', [ + 'yandex_creative_id' => 123, + ]); + + $r->assertStatus(404); + expect($r->json('message'))->toBe('Кампания не найдена.'); +}); + +test('GET /api/admin/advertising/campaigns — гейт: включён + нет REMOTE_USER → 403', function () { + config(['admin.basic_auth_gate' => true]); + config(['admin.basic_auth_allowlist' => ['admin']]); + + $this->getJson('/api/admin/advertising/campaigns')->assertStatus(403); +}); diff --git a/app/tests/Feature/Admin/AdminAdCpmTest.php b/app/tests/Feature/Admin/AdminAdCpmTest.php new file mode 100644 index 00000000..16415864 --- /dev/null +++ b/app/tests/Feature/Admin/AdminAdCpmTest.php @@ -0,0 +1,65 @@ +getJson('/api/admin/advertising/settings'); + + $r->assertStatus(200); + expect($r->json('client_cpm_rub'))->toBe('120.00'); +}); + +test('PUT /api/admin/advertising/settings обновляет client_cpm_rub, GET показывает новое значение', function () { + $r = $this->putJson('/api/admin/advertising/settings', ['client_cpm_rub' => '90.00']); + + $r->assertStatus(200); + expect($r->json('client_cpm_rub'))->toBe('90.00'); + + expect(DB::table('ad_settings')->value('client_cpm_rub'))->toBe('90.00'); + + $get = $this->getJson('/api/admin/advertising/settings'); + expect($get->json('client_cpm_rub'))->toBe('90.00'); +}); + +test('PUT /api/admin/advertising/settings с client_cpm_rub=0 → 422', function () { + $this->putJson('/api/admin/advertising/settings', ['client_cpm_rub' => '0']) + ->assertStatus(422); +}); + +test('PUT /api/admin/advertising/settings без поля client_cpm_rub → 422', function () { + $this->putJson('/api/admin/advertising/settings', []) + ->assertStatus(422); +}); + +test('GET /api/admin/advertising/settings отдаёт ad_margin_percent (дефолт 40.00)', function () { + $r = $this->getJson('/api/admin/advertising/settings'); + + $r->assertStatus(200); + expect($r->json('ad_margin_percent'))->toBe('40.00'); +}); + +test('PUT /api/admin/advertising/settings обновляет ad_margin_percent, GET показывает новое значение', function () { + $r = $this->putJson('/api/admin/advertising/settings', ['ad_margin_percent' => '35']); + + $r->assertStatus(200); + expect($r->json('ad_margin_percent'))->toBe('35.00'); + + expect(DB::table('ad_settings')->value('ad_margin_percent'))->toBe('35.00'); + + $get = $this->getJson('/api/admin/advertising/settings'); + expect($get->json('ad_margin_percent'))->toBe('35.00'); +}); + +test('PUT /api/admin/advertising/settings с ad_margin_percent=95 → 422 (>90)', function () { + $this->putJson('/api/admin/advertising/settings', ['ad_margin_percent' => '95']) + ->assertStatus(422); +}); diff --git a/app/tests/Feature/Admin/AdminRobotStuckTest.php b/app/tests/Feature/Admin/AdminRobotStuckTest.php new file mode 100644 index 00000000..3b81643e --- /dev/null +++ b/app/tests/Feature/Admin/AdminRobotStuckTest.php @@ -0,0 +1,83 @@ +create(['organization_name' => 'Окна Москва']); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания', + 'status' => AdCampaign::STATUS_REJECTED, + 'audience_days' => 10, + 'budget_rub' => '500.00', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'bytes' => 1000, 'included' => true, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'yandex_ad_id' => 17787055204, + ]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'kind' => $kind, 'status' => $status, + 'yandex_ad_id' => $banner->yandex_ad_id, + 'failure_reason' => 'разметка не совпала: окна BannerStatusCell.Popup нет', + 'finished_at' => now(), + ]); + + return [$tenant, $campaign, $job]; +} + +test('в списке ждёт разбора видно кампанию, где разведка сорвалась', function () { + [$tenant, $campaign] = stuckSetup(AdCreativeJob::STATUS_FAILED); + + $r = $this->getJson('/api/admin/advertising/robot-stuck'); + $r->assertStatus(200); + + $row = collect($r->json('data'))->firstWhere('campaign_id', $campaign->id); + + expect($row)->not->toBeNull() + ->and($row['tenant_name'])->toBe('Окна Москва') + ->and($row['failure_reason'])->toContain('разметка не совпала') + ->and((int) $row['yandex_ad_id'])->toBe(17787055204); +}); + +/** Удачная разведка разбора не ждёт — она уже всё рассказала клиенту в переписке. */ +test('удачная разведка в список ждёт разбора не попадает', function () { + [, $campaign] = stuckSetup(AdCreativeJob::STATUS_DONE); + + $r = $this->getJson('/api/admin/advertising/robot-stuck'); + + expect(collect($r->json('data'))->firstWhere('campaign_id', $campaign->id))->toBeNull(); +}); + +/** + * Сорванная ЗАЛИВКА картинок — тоже сбой робота, и владельцу её видеть надо: кампания + * стоит и не запускается. Список один на все виды заданий. + */ +test('сорванная заливка картинок в списке тоже видна', function () { + [, $campaign] = stuckSetup(AdCreativeJob::STATUS_FAILED, AdCreativeJob::KIND_UPLOAD); + + $r = $this->getJson('/api/admin/advertising/robot-stuck'); + + expect(collect($r->json('data'))->firstWhere('campaign_id', $campaign->id))->not->toBeNull(); +}); diff --git a/app/tests/Feature/AdminAdvertisingSpendTest.php b/app/tests/Feature/AdminAdvertisingSpendTest.php new file mode 100644 index 00000000..58ea35ff --- /dev/null +++ b/app/tests/Feature/AdminAdvertisingSpendTest.php @@ -0,0 +1,119 @@ +insert([ + 'tenant_id' => $tenantId, + 'type' => $type, + 'amount_rub' => $type === 'charge' ? -$amount : $amount, + 'balance_rub_after' => '0.00', + 'channel' => $channel, + 'related_type' => 'campaign', + 'related_id' => 1, + 'created_at' => $createdAt ?? now(), + ]); +} + +test('GET /api/admin/advertising/spend считает расход/маржу по тенантам (390/234/156 и 100/60/40)', function () { + $a = Tenant::factory()->create(['organization_name' => 'Окна Москва']); + $b = Tenant::factory()->create(['organization_name' => 'Двери СПб']); + + makeAdCharge($a->id, 260.00); + makeAdCharge($a->id, 130.00); + makeAdCharge($b->id, 100.00); + + $r = $this->getJson('/api/admin/advertising/spend'); + $r->assertStatus(200); + + expect($r->json('ad_margin_percent'))->toBe('40.00'); + + $rowA = collect($r->json('data'))->firstWhere('tenant_id', $a->id); + expect($rowA['tenant_name'])->toBe('Окна Москва'); + expect($rowA['client_spend_rub'])->toBe('390.00'); + expect($rowA['yandex_cost_rub'])->toBe('234.00'); + expect($rowA['our_margin_rub'])->toBe('156.00'); + + $rowB = collect($r->json('data'))->firstWhere('tenant_id', $b->id); + expect($rowB['client_spend_rub'])->toBe('100.00'); + expect($rowB['yandex_cost_rub'])->toBe('60.00'); + expect($rowB['our_margin_rub'])->toBe('40.00'); + + expect($r->json('totals.client_spend_rub'))->toBe('490.00'); + expect($r->json('totals.yandex_cost_rub'))->toBe('294.00'); + expect($r->json('totals.our_margin_rub'))->toBe('196.00'); +}); + +test('GET /api/admin/advertising/spend сортирует по client_spend_rub desc', function () { + $small = Tenant::factory()->create(); + $big = Tenant::factory()->create(); + + makeAdCharge($small->id, 50.00); + makeAdCharge($big->id, 500.00); + + $r = $this->getJson('/api/admin/advertising/spend'); + + expect($r->json('data.0.tenant_id'))->toBe($big->id); + expect($r->json('data.1.tenant_id'))->toBe($small->id); +}); + +test('GET /api/admin/advertising/spend игнорирует не-charge и не-yandex строки', function () { + $tenant = Tenant::factory()->create(); + + makeAdCharge($tenant->id, 200.00); // считается + makeAdCharge($tenant->id, 999.00, null, 'sms'); // другой канал — не считается + makeAdCharge($tenant->id, 999.00, null, 'yandex', 'topup'); // не charge — не считается + + $r = $this->getJson('/api/admin/advertising/spend'); + + $row = collect($r->json('data'))->firstWhere('tenant_id', $tenant->id); + expect($row['client_spend_rub'])->toBe('200.00'); +}); + +test('GET /api/admin/advertising/spend не включает тенанта без расхода', function () { + Tenant::factory()->create(); // без единой ad_wallet_transactions строки + + $r = $this->getJson('/api/admin/advertising/spend'); + + expect($r->json('data'))->toBe([]); + expect($r->json('totals.client_spend_rub'))->toBe('0.00'); +}); + +test('GET /api/admin/advertising/spend period=current_month не включает прошлый месяц, period=all включает', function () { + $tenant = Tenant::factory()->create(); + + makeAdCharge($tenant->id, 150.00); // текущий месяц + makeAdCharge($tenant->id, 700.00, now()->subMonths(2)->toDateTimeString()); // 2 месяца назад + + $current = $this->getJson('/api/admin/advertising/spend?period=current_month'); + $row = collect($current->json('data'))->firstWhere('tenant_id', $tenant->id); + expect($row['client_spend_rub'])->toBe('150.00'); + + $all = $this->getJson('/api/admin/advertising/spend?period=all'); + $rowAll = collect($all->json('data'))->firstWhere('tenant_id', $tenant->id); + expect($rowAll['client_spend_rub'])->toBe('850.00'); +}); + +test('GET /api/admin/advertising/spend — гейт: включён + нет REMOTE_USER → 403', function () { + config(['admin.basic_auth_gate' => true]); + config(['admin.basic_auth_allowlist' => ['admin']]); + + $this->getJson('/api/admin/advertising/spend')->assertStatus(403); +}); diff --git a/app/tests/Feature/Advertising/AdCampaignBannerMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignBannerMigrationTest.php new file mode 100644 index 00000000..1ab12b08 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignBannerMigrationTest.php @@ -0,0 +1,12 @@ +toBeTrue(); + foreach (['id', 'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'created_at'] as $col) { + expect(Schema::hasColumn('ad_campaign_banners', $col))->toBeTrue("нет колонки {$col}"); + } +}); diff --git a/app/tests/Feature/Advertising/AdCampaignBannersApprovedMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignBannersApprovedMigrationTest.php new file mode 100644 index 00000000..9a86c2a6 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignBannersApprovedMigrationTest.php @@ -0,0 +1,18 @@ +toBeTrue(); + + $tenant = Tenant::factory()->create(); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + 'banners_approved_at' => now(), + ]); + expect($c->banners_approved_at)->not->toBeNull(); +}); diff --git a/app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php new file mode 100644 index 00000000..9f113954 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignChildTablesMigrationTest.php @@ -0,0 +1,63 @@ +create(); + + $campaignId = DB::table('ad_campaigns')->insertGetId([ + 'tenant_id' => $tenant->id, + 'channel' => 'yandex', + 'name' => 'Кампания №1', + 'status' => 'draft', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '2500.00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('ad_campaign_ads')->insert([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaignId, + 'title' => 'Заголовок объявления', + 'title2' => 'Второй заголовок', + 'text' => 'Текст объявления с описанием услуги', + 'href' => 'https://liderra.ru/promo', + 'image_normal_hash' => null, + 'image_wide_hash' => null, + 'yandex_ad_id' => null, + 'moderation_status' => 'draft', + 'moderation_reason' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaignId, + 'phone' => '79990000001', + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(DB::table('ad_campaign_ads')->where('campaign_id', $campaignId)->count())->toBe(1) + ->and(DB::table('ad_campaign_phones')->where('campaign_id', $campaignId)->count())->toBe(1); + + $ad = DB::table('ad_campaign_ads')->where('campaign_id', $campaignId)->first(); + expect($ad->tenant_id)->toBe($tenant->id) + ->and($ad->title)->toBe('Заголовок объявления') + ->and($ad->title2)->toBe('Второй заголовок') + ->and($ad->text)->toBe('Текст объявления с описанием услуги') + ->and($ad->href)->toBe('https://liderra.ru/promo') + ->and($ad->moderation_status)->toBe('draft'); + + $phone = DB::table('ad_campaign_phones')->where('campaign_id', $campaignId)->first(); + expect($phone->tenant_id)->toBe($tenant->id) + ->and($phone->phone)->toBe('79990000001') + ->and($phone->expires_at)->toBeNull(); +}); diff --git a/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php new file mode 100644 index 00000000..d88fa7bc --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php @@ -0,0 +1,19 @@ +toBeTrue(); +}); + +it('модель AdCampaign позволяет задать и прочитать yandex_creative_id как integer', function () { + $campaign = new AdCampaign; + $campaign->fill(['yandex_creative_id' => '123456789']); + + expect($campaign->yandex_creative_id)->toBe(123456789) + ->and($campaign->getCasts()['yandex_creative_id'] ?? null)->toBe('integer') + ->and(in_array('yandex_creative_id', $campaign->getFillable(), true))->toBeTrue(); +}); diff --git a/app/tests/Feature/Advertising/AdCampaignImpressionFieldsTest.php b/app/tests/Feature/Advertising/AdCampaignImpressionFieldsTest.php new file mode 100644 index 00000000..7a29f260 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignImpressionFieldsTest.php @@ -0,0 +1,38 @@ +toBeTrue("нет колонки {$col}"); + } +}); + +it('позволяет создать кампанию БЕЗ weekly_budget_rub (клик-поле больше не обязательно)', function () { + $tenant = Tenant::factory()->create(); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Прогрев за показы', + 'audience_days' => 30, + 'use_uploaded_list' => true, + 'frequency' => 15, + 'estimated_impressions' => 24450, + 'paid_impressions' => 24450, + 'budget_rub' => '2934.00', + ]); + + expect($c->status)->toBe(AdCampaign::STATUS_DRAFT) + ->and($c->weekly_budget_rub)->toBeNull() + ->and($c->frequency)->toBe(15) + ->and($c->estimated_impressions)->toBe(24450) + ->and($c->delivered_impressions)->toBe(0) + ->and($c->budget_rub)->toBe('2934.00'); +}); diff --git a/app/tests/Feature/Advertising/AdCampaignMigrationTest.php b/app/tests/Feature/Advertising/AdCampaignMigrationTest.php new file mode 100644 index 00000000..a101a777 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignMigrationTest.php @@ -0,0 +1,40 @@ +create(); + + DB::table('ad_campaigns')->insert([ + 'tenant_id' => $tenant->id, + 'channel' => 'yandex', + 'name' => 'Кампания №1', + 'status' => 'draft', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'daily_budget_rub' => null, + 'yandex_segment_id' => 58034825, + 'yandex_retargeting_list_id' => 777, + 'yandex_campaign_id' => 111222, + 'yandex_ad_group_id' => 333444, + 'moderation_reason' => null, + 'launched_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $row = DB::table('ad_campaigns')->where('tenant_id', $tenant->id)->first(); + + expect($row->channel)->toBe('yandex') + ->and($row->name)->toBe('Кампания №1') + ->and($row->status)->toBe('draft') + ->and((int) $row->audience_days)->toBe(10) + ->and((bool) $row->use_uploaded_list)->toBeFalse() + ->and((int) $row->yandex_segment_id)->toBe(58034825) + ->and((int) $row->yandex_retargeting_list_id)->toBe(777) + ->and((int) $row->yandex_campaign_id)->toBe(111222) + ->and((int) $row->yandex_ad_group_id)->toBe(333444); +}); diff --git a/app/tests/Feature/Advertising/AdCampaignModelTest.php b/app/tests/Feature/Advertising/AdCampaignModelTest.php new file mode 100644 index 00000000..d41bd346 --- /dev/null +++ b/app/tests/Feature/Advertising/AdCampaignModelTest.php @@ -0,0 +1,21 @@ +create(); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'Свежие клиенты', 'audience_days' => 10, + 'use_uploaded_list' => false, + ]); + AdCampaignAd::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $c->id, + 'title' => 'Заголовок', 'text' => 'Текст', 'href' => 'https://example.com', + ]); + expect($c->status)->toBe(AdCampaign::STATUS_DRAFT) + ->and($c->ads()->count())->toBe(1); +}); diff --git a/app/tests/Feature/Advertising/AdCreativeJobModelTest.php b/app/tests/Feature/Advertising/AdCreativeJobModelTest.php new file mode 100644 index 00000000..52e99b4b --- /dev/null +++ b/app/tests/Feature/Advertising/AdCreativeJobModelTest.php @@ -0,0 +1,34 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'snapshot_before' => [111 => [300, 250]], + ]); + + $fresh = $job->fresh(); + expect($fresh->status)->toBe('queued') + ->and($fresh->attempts)->toBe(0) + ->and($fresh->snapshot_before)->toBe(['111' => [300, 250]]) + ->and($fresh->failure_reason)->toBeNull(); +}); diff --git a/app/tests/Feature/Advertising/AdSettingsClientCpmMigrationTest.php b/app/tests/Feature/Advertising/AdSettingsClientCpmMigrationTest.php new file mode 100644 index 00000000..2b5a6a58 --- /dev/null +++ b/app/tests/Feature/Advertising/AdSettingsClientCpmMigrationTest.php @@ -0,0 +1,13 @@ +toBeTrue(); + + $cpm = DB::table('ad_settings')->value('client_cpm_rub'); + expect((string) $cpm)->toBe('120.00'); +}); diff --git a/app/tests/Feature/Advertising/AdStopAllTest.php b/app/tests/Feature/Advertising/AdStopAllTest.php new file mode 100644 index 00000000..d419aa0a --- /dev/null +++ b/app/tests/Feature/Advertising/AdStopAllTest.php @@ -0,0 +1,24 @@ +create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '2500.00']); + + $gate = app(AdWalletGate::class); + expect($gate->isSolvent($tenant->id))->toBeFalse(); +}); + +it('dispatches AdvertisingStopped when stopping all advertising', function () { + Event::fake(); + app(AdStopAllService::class)->stopAll(123); + Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === 123); +}); diff --git a/app/tests/Feature/Advertising/AdWalletChargeTest.php b/app/tests/Feature/Advertising/AdWalletChargeTest.php new file mode 100644 index 00000000..29e6ce06 --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletChargeTest.php @@ -0,0 +1,21 @@ +create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '2500.00']); + $svc = app(AdWalletService::class); + + $svc->charge($tenant->id, channel: 'yandex', relatedType: 'campaign', relatedId: 1, + amountRub: '260.00', externalKey: 'yandex:1:2026-07-24'); + // повтор того же события — не списывает второй раз + $svc->charge($tenant->id, 'yandex', 'campaign', 1, '260.00', 'yandex:1:2026-07-24'); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('2740.00'); // 3000 − 260, списано ОДИН раз +}); diff --git a/app/tests/Feature/Advertising/AdWalletFreezeTest.php b/app/tests/Feature/Advertising/AdWalletFreezeTest.php new file mode 100644 index 00000000..6e22360d --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletFreezeTest.php @@ -0,0 +1,32 @@ +create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + + $svc->freeze($tenant->id, channel: 'yandex', sourceType: 'campaign', sourceId: 1, amountRub: '2500.00'); + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('2500.00'); + + // свободно 500 < 2500 → нельзя заморозить второй раз + expect(fn () => $svc->freeze($tenant->id, 'yandex', 'campaign', 2, '2500.00')) + ->toThrow(InsufficientBalanceException::class); +}); + +it('releases a hold back to free balance', function () { + $tenant = Tenant::factory()->create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '2500.00'); + + $svc->release($tenant->id, 'yandex', 'campaign', 1); + expect(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); diff --git a/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php b/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php new file mode 100644 index 00000000..8882ae58 --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php @@ -0,0 +1,86 @@ + $tenantId, + 'name' => 'Кампания жизненного цикла заморозки', + 'status' => AdCampaign::STATUS_RUNNING, + 'audience_days' => 30, + 'use_uploaded_list' => true, + 'frequency' => 15, + 'estimated_impressions' => $paidImpressions, + 'paid_impressions' => $paidImpressions, + ]); +} + +/** Активных холдов у тенанта не осталось. */ +function activeHoldCount(int $tenantId): int +{ + return AdWalletHold::where('tenant_id', $tenantId) + ->where('status', AdWalletHold::STATUS_ACTIVE)->count(); +} + +/** + * Жизненный цикл заморозки рекламного кошелька. + * + * Заморозка ставится ОДИН раз при запуске кампании на всю смету показов, а + * дальше должна ТАЯТЬ по мере фактических списаний: иначе одни и те же деньги + * считаются дважды (ушли с баланса и остались в заморозке), свободный остаток + * уходит в минус, а AdWalletGate::isSolvent() объявляет клиента неплатёжеспособным + * после первого же суточного списания и AdStopAll глушит все его кампании. + */ +it('melts the active hold while charging so free balance is not double-counted', function () { + $tenant = Tenant::factory()->create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '1000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + + $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '1000.00'); + $svc->charge($tenant->id, 'yandex', 'campaign', 1, '200.00', 'yandex-imp:1:100'); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + + // Списанные 200 ₽ ушли с баланса и ОДНОВРЕМЕННО перестали быть зарезервированы. + expect($wallet->balance_rub)->toBe('800.00') + ->and($wallet->frozen_rub)->toBe('800.00'); + + // Свободно = 0 (вся смета ещё занята кампанией), но клиент платёжеспособен — + // кампанию не должно глушить после первого списания. + expect(app(AdWalletGate::class)->isSolvent($tenant->id))->toBeTrue(); + + // Сам холд тоже похудел — иначе финальный release вернул бы больше, чем занято. + expect(AdWalletHold::where('tenant_id', $tenant->id)->where('source_id', 1)->first()->amount_rub) + ->toBe('800.00'); +}); + +// ВЫХОД 1 — кампания открутила смету и помечена «завершена». +it('releases the remaining hold when the campaign completes', function () { + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makeHoldLifecycleCampaign($tenant->id, 10000); + + // Заморожено с запасом (1500 ₽) над фактической стоимостью сметы (10000 показов × 120 ₽/1000 = 1200 ₽). + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1500.00'); + + app(CampaignImpressionCharger::class)->charge($campaign, 10000); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED) + ->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00') + ->and(activeHoldCount($tenant->id))->toBe(0); +}); diff --git a/app/tests/Feature/Advertising/AdWalletInvoiceTopupTest.php b/app/tests/Feature/Advertising/AdWalletInvoiceTopupTest.php new file mode 100644 index 00000000..36dd0617 --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletInvoiceTopupTest.php @@ -0,0 +1,69 @@ +create(['balance_rub' => $balance]); + User::factory()->create(['tenant_id' => $tenant->id]); + $le = LegalEntity::create([ + 'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP', + 'inn' => '770000000099', 'is_default' => true, + ]); + $invoice = SaasInvoice::create([ + 'tenant_id' => $tenant->id, 'legal_entity_id' => $le->id, + 'invoice_number' => 'СЧ-2026-00'.random_int(1000, 9999), 'payer_type' => 'legal', 'payer_name' => 'ООО К', + 'payer_inn' => '5000000000', 'amount_net' => $amount, 'amount_total' => $amount, + 'credit_target' => $creditTarget, + 'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5), + ]); + + return [$tenant, $invoice]; +} + +it('счёт с credit_target=advertising зачисляет рекламный кошелёк, не баланс за лиды', function () { + Storage::fake('local'); + Mail::fake(); + [$tenant, $invoice] = seedInvoiceForCreditTarget('100.00', '1000.00', 'advertising'); + + app(InvoicePaymentService::class)->markPaid($invoice->id); + + $invoice->refresh(); + $tenant->refresh(); + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + + expect($invoice->status)->toBe(SaasInvoice::STATUS_PAID) + ->and((string) $wallet->balance_rub)->toBe('1000.00') + ->and((string) $tenant->balance_rub)->toBe('100.00'); // баланс за лиды НЕ тронут +}); + +it('счёт с credit_target=leads (умолчание) зачисляет баланс за лиды, не рекламный кошелёк', function () { + Storage::fake('local'); + Mail::fake(); + [$tenant, $invoice] = seedInvoiceForCreditTarget('100.00', '1500.00', 'leads'); + + app(InvoicePaymentService::class)->markPaid($invoice->id); + + $tenant->refresh(); + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + + expect((string) $tenant->balance_rub)->toBe('1600.00') + ->and($wallet)->toBeNull(); +}); diff --git a/app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php b/app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php new file mode 100644 index 00000000..1242cbdd --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletLowBalanceMailTest.php @@ -0,0 +1,34 @@ +create(['contact_email' => 'owner@example.com']); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '2500.00']); + + app(AdStopAllService::class)->stopAll($tenant->id); + + Mail::assertQueued( + AdWalletLowBalanceNotification::class, + fn ($mail) => $mail->hasTo($tenant->contact_email) + ); +}); + +it('skips mail (no crash) when tenant does not exist', function () { + Mail::fake(); + + app(AdStopAllService::class)->stopAll(999999); + + Mail::assertNothingQueued(); +}); diff --git a/app/tests/Feature/Advertising/AdWalletMigrationTest.php b/app/tests/Feature/Advertising/AdWalletMigrationTest.php new file mode 100644 index 00000000..a5b4744b --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletMigrationTest.php @@ -0,0 +1,22 @@ +create(); + + DB::table('ad_wallets')->insert([ + 'tenant_id' => $tenant->id, + 'balance_rub' => '0.00', + 'frozen_rub' => '0.00', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $row = DB::table('ad_wallets')->where('tenant_id', $tenant->id)->first(); + expect($row->balance_rub)->toBe('0.00') + ->and($row->frozen_rub)->toBe('0.00'); +}); diff --git a/app/tests/Feature/Advertising/AdWalletModelTest.php b/app/tests/Feature/Advertising/AdWalletModelTest.php new file mode 100644 index 00000000..b69da4fb --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletModelTest.php @@ -0,0 +1,16 @@ +create(); + $wallet = AdWallet::create([ + 'tenant_id' => $tenant->id, + 'balance_rub' => '1000.00', + 'frozen_rub' => '0.00', + ]); + expect($wallet->balance_rub)->toBe('1000.00'); +}); diff --git a/app/tests/Feature/Advertising/AdWalletRefreezeTest.php b/app/tests/Feature/Advertising/AdWalletRefreezeTest.php new file mode 100644 index 00000000..0580fabb --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletRefreezeTest.php @@ -0,0 +1,34 @@ +create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + + $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '1000.00'); + $svc->release($tenant->id, 'yandex', 'campaign', 1); + + // Возобновление той же кампании: та же четвёрка ключа, бронь уже released. + $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '1000.00'); + + expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('1000.00'); +}); diff --git a/app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php b/app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php new file mode 100644 index 00000000..85362f6d --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletTablesMigrationTest.php @@ -0,0 +1,35 @@ +create(); + + DB::table('ad_wallet_transactions')->insert([ + 'tenant_id' => $tenant->id, + 'type' => 'topup', + 'amount_rub' => '1000.00', + 'balance_rub_after' => '1000.00', + 'channel' => null, + 'related_type' => null, + 'related_id' => null, + 'description' => 'test', + 'created_at' => now(), + ]); + DB::table('ad_wallet_holds')->insert([ + 'tenant_id' => $tenant->id, + 'channel' => 'yandex', + 'source_type' => 'campaign', + 'source_id' => 1, + 'amount_rub' => '2500.00', + 'status' => 'active', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + expect(DB::table('ad_wallet_transactions')->where('tenant_id', $tenant->id)->count())->toBe(1) + ->and(DB::table('ad_wallet_holds')->where('tenant_id', $tenant->id)->count())->toBe(1); +}); diff --git a/app/tests/Feature/Advertising/AdWalletTopupTest.php b/app/tests/Feature/Advertising/AdWalletTopupTest.php new file mode 100644 index 00000000..dd9018a7 --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletTopupTest.php @@ -0,0 +1,20 @@ +create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']); + + app(AdWalletService::class)->topup($tenant->id, '1000.00', channel: null, description: 'ЮKassa'); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('1000.00'); + $this->assertDatabaseHas('ad_wallet_transactions', [ + 'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => '1000.00', + ]); +}); diff --git a/app/tests/Feature/Advertising/AdWalletUnderRealRoleTest.php b/app/tests/Feature/Advertising/AdWalletUnderRealRoleTest.php new file mode 100644 index 00000000..b9e64d25 --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletUnderRealRoleTest.php @@ -0,0 +1,56 @@ +create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + + $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '1000.00'); + + // Яндекс отклонил весь набор объявлений: джоб возвращает клиенту заморозку целиком. + podBoevoyRolyu(fn () => $svc->release($tenant->id, 'yandex', 'campaign', 1)); + + // Читаем суперюзером — прибор не должен зависеть от той же защиты, что проверяем. + expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); + +it('замораживает под боевой ролью без контекста клиента', function () { + $tenant = Tenant::factory()->create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + + podBoevoyRolyu(fn () => $svc->freeze($tenant->id, 'yandex', 'campaign', 2, '700.00')); + + expect((string) AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('700.00'); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignDeleteTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignDeleteTest.php new file mode 100644 index 00000000..4c2545dd --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingCampaignDeleteTest.php @@ -0,0 +1,104 @@ +create(). + */ +beforeEach(function () { + $this->tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +it('deletes a draft campaign of own tenant', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Черновик на удаление', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('ad_campaigns', ['id' => $campaign->id]); +}); + +it('refuses to delete a running campaign', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Запущенная кампания', + 'status' => AdCampaign::STATUS_RUNNING, + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}"); + + $response->assertStatus(409); + $this->assertDatabaseHas('ad_campaigns', ['id' => $campaign->id]); +}); + +it('returns 404 for another tenants campaign and does not delete it', function () { + $tenantB = Tenant::factory()->create(); + $campaignB = AdCampaign::create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Чужой черновик', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->deleteJson("/api/advertising/campaigns/{$campaignB->id}"); + + $response->assertStatus(404); + $this->assertDatabaseHas('ad_campaigns', ['id' => $campaignB->id]); +}); + +it('returns 404 for a non-existent campaign', function () { + $response = $this->deleteJson('/api/advertising/campaigns/999999999'); + + $response->assertStatus(404); +}); + +it('cascades delete to related ads and phones', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Черновик с детьми', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $ad = AdCampaignAd::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'title' => 'Заголовок', + 'text' => 'Текст объявления', + 'href' => 'https://liderra.ru', + 'moderation_status' => 'draft', + ]); + + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'phone' => '79000000001', + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->deleteJson("/api/advertising/campaigns/{$campaign->id}"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('ad_campaign_ads', ['id' => $ad->id]); + $this->assertDatabaseMissing('ad_campaign_phones', ['campaign_id' => $campaign->id]); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php new file mode 100644 index 00000000..3d4d28a3 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php @@ -0,0 +1,837 @@ +create(). + */ +beforeEach(function () { + $this->tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +function seedCampaignPhones(AdCampaign $campaign, int $count): void +{ + $rows = []; + for ($i = 0; $i < $count; $i++) { + $rows[] = [ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'phone' => sprintf('798800%05d', $i), + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + DB::table('ad_campaign_phones')->insert($rows); +} + +it('returns 401 without auth', function () { + auth()->logout(); + + $this->getJson('/api/advertising/campaigns')->assertStatus(401); +}); + +it('creates a draft campaign with valid body', function () { + $response = $this->postJson('/api/advertising/campaigns', [ + 'name' => 'Кампания А', + 'audience_days' => 10, + 'weekly_budget_rub' => '1500.00', + ]); + + $response->assertCreated(); + + $campaignId = $response->json('id'); + expect($campaignId)->not->toBeNull(); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $campaignId, + 'tenant_id' => $this->tenant->id, + 'status' => AdCampaign::STATUS_DRAFT, + ]); +}); + +it('rejects an invalid body with 422', function () { + $response = $this->postJson('/api/advertising/campaigns', [ + 'name' => '', + 'audience_days' => 0, + 'weekly_budget_rub' => '1500.00', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'audience_days']); +}); + +it('lists tenant campaigns and shows one with ads and spent_rub', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания B', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $index = $this->getJson('/api/advertising/campaigns'); + $index->assertOk(); + expect(collect($index->json('data'))->pluck('id')->all())->toContain($campaign->id); + + $show = $this->getJson("/api/advertising/campaigns/{$campaign->id}"); + $show->assertOk() + ->assertJsonPath('campaign.id', $campaign->id) + ->assertJsonPath('ads', []) + ->assertJsonPath('spent_rub', '0.00'); +}); + +it('sums charge transactions as spent_rub', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания расход', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '1000.00', 'frozen_rub' => '0.00']); + + AdWalletTransaction::create([ + 'tenant_id' => $this->tenant->id, + 'type' => AdWalletTransaction::TYPE_CHARGE, + 'amount_rub' => '-260.00', + 'balance_rub_after' => '740.00', + 'channel' => 'yandex', + 'related_type' => 'campaign', + 'related_id' => $campaign->id, + 'external_key' => 'yandex:'.$campaign->id.':2026-07-20', + 'created_at' => now(), + ]); + AdWalletTransaction::create([ + 'tenant_id' => $this->tenant->id, + 'type' => AdWalletTransaction::TYPE_CHARGE, + 'amount_rub' => '-40.00', + 'balance_rub_after' => '700.00', + 'channel' => 'yandex', + 'related_type' => 'campaign', + 'related_id' => $campaign->id, + 'external_key' => 'yandex:'.$campaign->id.':2026-07-21', + 'created_at' => now(), + ]); + + $show = $this->getJson("/api/advertising/campaigns/{$campaign->id}"); + $show->assertOk()->assertJsonPath('spent_rub', '300.00'); +}); + +it('updates a campaign partially', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'До правки', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'name' => 'После правки', + 'budget_rub' => '2000.00', + ]); + + $response->assertOk() + ->assertJsonPath('name', 'После правки') + ->assertJsonPath('budget_rub', '2000.00'); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $campaign->id, + 'name' => 'После правки', + 'budget_rub' => '2000.00', + ]); +}); + +it('refuses to change the impression budget once the campaign is created in Yandex', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Уже в Яндексе', + 'audience_days' => 10, + 'estimated_impressions' => 100000, + 'client_cpm_rub' => '120.00', + 'landing_url' => 'https://liderra.ru/promo', + 'yandex_campaign_id' => 222, + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'estimated_impressions' => 5000, + 'client_cpm_rub' => '10.00', + ]); + + $response->assertStatus(409); + expect($response->json('message'))->not->toBeNull(); + + // Смета в базе НЕ изменилась — иначе портал показывал бы одни числа, а Яндекс крутил другие. + $campaign->refresh(); + expect($campaign->estimated_impressions)->toBe(100000) + ->and($campaign->client_cpm_rub)->toBe('120.00'); +}); + +it('refuses to change landing_url once the campaign is created in Yandex', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Уже в Яндексе, адрес', + 'audience_days' => 10, + 'landing_url' => 'https://liderra.ru/promo', + 'yandex_campaign_id' => 222, + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'landing_url' => 'https://liderra.ru/promo-new', + ]); + + $response->assertStatus(409); + + $campaign->refresh(); + expect($campaign->landing_url)->toBe('https://liderra.ru/promo'); +}); + +/** + * Замок стоял только по номеру кампании Директа, а сегмент Яндекс.Аудиторий создаётся + * РАНЬШЕ неё. Обрыв запуска в этом окне (сегмент уже есть, кампании ещё нет) оставлял + * настройки аудитории открытыми: клиент менял срок сбора или список номеров, портал + * показывал новое, а возобновлённый запуск переиспользовал СТАРЫЙ сегмент — реклама + * шла по прежним телефонам. + */ +it('refuses to change audience settings once the Yandex audience segment exists', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Сегмент уже создан', + 'audience_days' => 10, + 'estimated_impressions' => 100000, + 'yandex_segment_id' => 900001, + // Кампании в Директе ещё нет — запуск оборвался ровно между сегментом и кампанией. + 'yandex_campaign_id' => null, + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'audience_days' => 30, + ]); + + $response->assertStatus(409); + + $campaign->refresh(); + expect($campaign->audience_days)->toBe(10); +}); + +it('still allows renaming a campaign whose audience segment exists', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Старое имя при сегменте', + 'audience_days' => 10, + 'yandex_segment_id' => 900001, + ]); + + $this->patchJson("/api/advertising/campaigns/{$campaign->id}", ['name' => 'Новое имя']) + ->assertOk()->assertJsonPath('name', 'Новое имя'); +}); + +it('still allows renaming a campaign that is already created in Yandex', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Старое имя', + 'audience_days' => 10, + 'estimated_impressions' => 100000, + 'yandex_campaign_id' => 222, + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'name' => 'Новое имя', + ]); + + $response->assertOk()->assertJsonPath('name', 'Новое имя'); + + $campaign->refresh(); + expect($campaign->name)->toBe('Новое имя') + ->and($campaign->estimated_impressions)->toBe(100000); +}); + +it('still allows editing the impression budget of a plain draft with nothing in Yandex', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Обычный черновик', + 'audience_days' => 10, + 'estimated_impressions' => 1000, + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'estimated_impressions' => 5000, + ]); + + $response->assertOk(); + + $campaign->refresh(); + expect($campaign->estimated_impressions)->toBe(5000); +}); + +it('creates a campaign with a valid landing_url and returns it', function () { + $response = $this->postJson('/api/advertising/campaigns', [ + 'name' => 'Кампания с адресом', + 'audience_days' => 10, + 'landing_url' => 'https://liderra.ru/promo', + ]); + + $response->assertCreated() + ->assertJsonPath('landing_url', 'https://liderra.ru/promo'); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $response->json('id'), + 'landing_url' => 'https://liderra.ru/promo', + ]); +}); + +it('rejects an invalid landing_url with 422 on store', function () { + $response = $this->postJson('/api/advertising/campaigns', [ + 'name' => 'Кампания с плохим адресом', + 'audience_days' => 10, + 'landing_url' => 'не-адрес', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['landing_url']); +}); + +it('updates landing_url on an existing campaign', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под адрес', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'landing_url' => 'https://liderra.ru/promo2', + ]); + + $response->assertOk()->assertJsonPath('landing_url', 'https://liderra.ru/promo2'); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $campaign->id, + 'landing_url' => 'https://liderra.ru/promo2', + ]); +}); + +it('rejects an invalid landing_url with 422 on update', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под плохой адрес', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->patchJson("/api/advertising/campaigns/{$campaign->id}", [ + 'landing_url' => 'не-адрес', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['landing_url']); +}); + +it('reports a small audience as not enough with a hint', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Малая аудитория', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10"); + + $response->assertOk() + ->assertJsonPath('min', 100) + ->assertJsonPath('enough', false); + expect($response->json('size'))->toBeLessThan(100); + expect($response->json('hint'))->not->toBeNull(); +}); + +it('reports a large audience as enough with no hint', function () { + // T3: список считается только в mode=manual (auto его больше не подмешивает) — + // передаём mode=manual в запросе, как это делает мастер. + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Большая аудитория', + 'audience_days' => 10, + 'use_uploaded_list' => true, + 'weekly_budget_rub' => '1000.00', + ]); + seedCampaignPhones($campaign, 150); + + $response = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10&mode=manual"); + + $response->assertOk() + ->assertJsonPath('enough', true) + ->assertJsonPath('hint', null); + expect($response->json('size'))->toBeGreaterThanOrEqual(100); +}); + +it('rejects a bad ad creative with 422 errors.title', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под объявление', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [ + 'title' => str_repeat('а', 60), + 'text' => 'Обычный текст объявления', + 'href' => 'https://liderra.ru', + ]); + + $response->assertStatus(422); + expect($response->json('errors.title'))->not->toBeNull(); +}); + +it('creates a valid ad creative', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под объявление 2', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads", [ + 'title' => 'Хороший заголовок', + 'text' => 'Хороший текст объявления в пределах нормы', + 'href' => 'https://liderra.ru', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('ad_campaign_ads', [ + 'campaign_id' => $campaign->id, + 'tenant_id' => $this->tenant->id, + 'title' => 'Хороший заголовок', + ]); +}); + +it('returns 422 with a message when launching with a too-small audience', function () { + config(['services.yandex_direct.enabled' => true]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Запуск малая аудитория', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '1000.00', + // Медийные поля — заполнены, чтобы дойти именно до проверки аудитории (не оборваться раньше). + 'estimated_impressions' => 10000, + 'landing_url' => 'https://liderra.ru', + ]); + // Набор баннеров с номерами креативов — иначе запуск оборвётся на проверке набора, + // а не на аудитории (адаптивного креатива у медийной кампании больше нет). + AdCampaignBanner::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4242, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch"); + + $response->assertStatus(422); + expect($response->json('message'))->not->toBeNull(); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +it('uploads a valid ad image and stores the returned hash', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + Http::fake(['*/json/v501/adimages' => Http::response(['result' => ['AddResults' => [['AdImageHash' => 'HASH123']]]])]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под картинку', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + $ad = AdCampaignAd::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'title' => 'Заголовок', + 'text' => 'Текст объявления', + 'href' => 'https://liderra.ru', + 'moderation_status' => 'draft', + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [ + 'file' => UploadedFile::fake()->image('creative.jpg', 500, 500), + ]); + + $response->assertOk()->assertJsonPath('hash', 'HASH123'); + $this->assertDatabaseHas('ad_campaign_ads', [ + 'id' => $ad->id, + 'image_normal_hash' => 'HASH123', + ]); +}); + +it('rejects an ad image upload when yandex_direct is disabled', function () { + config(['services.yandex_direct.enabled' => false]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под картинку 2', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + $ad = AdCampaignAd::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'title' => 'Заголовок', + 'text' => 'Текст объявления', + 'href' => 'https://liderra.ru', + 'moderation_status' => 'draft', + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [ + 'file' => UploadedFile::fake()->image('creative.jpg', 500, 500), + ]); + + $response->assertStatus(409); +}); + +it('isolates tenants: user of tenant A cannot see tenant B campaign', function () { + $tenantB = Tenant::factory()->create(); + $campaignB = AdCampaign::create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Чужая кампания', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->getJson("/api/advertising/campaigns/{$campaignB->id}"); + + $response->assertStatus(404); +}); + +it('queues a creative job instead of failing when banners have no creative numbers', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Запуск без креативов', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'estimated_impressions' => 10000, + 'landing_url' => 'https://liderra.ru', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + // yandex_creative_id намеренно НЕ задан — робот его ещё не привёз. + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch") + ->assertStatus(202) + ->assertJsonPath('status', 'creatives_pending'); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1) + ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +it('does not queue a second creative job on repeated launch', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Повторный запуск без креативов', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'estimated_impressions' => 10000, + 'landing_url' => 'https://liderra.ru', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202); + $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +/** + * Постановка задания роботу может не удаться (упала база, лёг Яндекс — исторически она + * ходила туда за слепком). Раньше клиент получал голый 500 «что-то пошло не так»: + * непонятно, виноват ли он, надо ли заливать картинки заново, стоит ли пробовать ещё раз. + * Ответ должен быть человеческим и означать «это не вы, попробуйте позже». + */ +it('answers politely instead of a bare error when the creative job cannot be queued', function () { + config(['services.yandex_direct.enabled' => true]); + Http::fake(); + + // Постановку задания роняем изнутри: причина не важна, важно, что клиент не увидит + // голого 500 ни при какой поломке. + $this->app->bind(CreativeJobService::class, function () { + throw new RuntimeException('очередь недоступна'); + }); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Очередь легла', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'estimated_impressions' => 10000, + 'landing_url' => 'https://liderra.ru', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch") + ->assertStatus(503) + ->assertJsonPath('status', 'yandex_unavailable'); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +/** + * 🪤 Мина той же породы, что уже ловилась в проекте: `config(...) === false`. + * + * Значение приходит из `env('YANDEX_DIRECT_ENABLED', false)`. Стоит написать в `.env` + * `YANDEX_DIRECT_ENABLED=0` — и Laravel вернёт строку «0», которая рубильником читается + * как «выключено», а сравнением `=== false` — как «включено». Запрос ушёл бы в живой + * Яндекс при выключенном рубильнике. Все остальные места проверяют через `! config(...)`. + */ +it('treats a string switch value as off, not on', function () { + config(['services.yandex_direct.enabled' => '0']); + Http::fake(); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Рубильник строкой', + 'audience_days' => 10, + 'use_uploaded_list' => false, + ]); + $ad = AdCampaignAd::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'title' => 'Заголовок', + 'text' => 'Текст объявления', + 'href' => 'https://liderra.ru', + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/ads/{$ad->id}/image", [ + 'file' => UploadedFile::fake()->image('b.jpg', 1080, 607), + ])->assertStatus(409); + + Http::assertNothingSent(); +}); + +it('does not touch yandex at all when the direct switch is off', function () { + config(['services.yandex_direct.enabled' => false]); + Http::fake(); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Рубильник выключен', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'estimated_impressions' => 10000, + 'landing_url' => 'https://liderra.ru', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$this->tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(409); + + Http::assertNothingSent(); + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** + * Красный ярлык «Отклонено» без единого слова объяснения — то, что клиент видел + * до 28.07.2026. Причина лежала в базе и никуда не отдавалась. + * + * Наценке в клиентском ответе места нет ни при каких обстоятельствах. + */ +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. + $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'); +}); + +/** Кампания заданного статуса, уже заведённая в Яндексе, со своим пользователем. */ +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); + + expect($campaignA->refresh()->status)->toBe(AdCampaign::STATUS_REJECTED); +}); + +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); +}); + +/** + * 🔴 Поймано живой проверкой в браузере 28.07.2026, все тесты при этом были зелёные. + * + * «Исправить» возвращает кампанию в черновик — и в ту же секунду исключение из замка + * перестаёт действовать: оно написано по статусу «отклонено», а статуса уже нет. + * Клиент оказывался в том же тупике, только шагом дальше: мастер открылся, а сервер + * не даёт ни сменить адрес сайта, ни перезалить картинку. + * + * Признак «отдана на починку» обязан пережить возврат в черновик. + */ +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) + ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) + ->assertOk(); +}); + +/** + * Оборвавшийся запуск оставляет черновик, у которого в Яндексе уже есть кампания, группа + * и сегмент — его правку замок обязан держать по-прежнему. Иначе клиент поменяет срок + * сбора, а возобновление переиспользует СТАРЫЙ сегмент, и реклама пойдёт по прежним номерам. + */ +it('черновик с оборвавшимся запуском по-прежнему заперт', function () { + [, $user, $campaign] = campaignInYandex(AdCampaign::STATUS_DRAFT); + + $this->actingAs($user) + ->patchJson("/api/advertising/campaigns/{$campaign->id}", ['landing_url' => 'https://liderra.ru/new']) + ->assertStatus(409); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php new file mode 100644 index 00000000..e0532c55 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php @@ -0,0 +1,357 @@ +create(). + */ +beforeEach(function () { + $this->tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +it('returns 401 for pause without auth', function () { + auth()->logout(); + + $this->postJson('/api/advertising/campaigns/1/pause')->assertStatus(401); +}); + +it('returns 401 for resume without auth', function () { + auth()->logout(); + + $this->postJson('/api/advertising/campaigns/1/resume')->assertStatus(401); +}); + +it('pauses a running campaign without calling Direct when disabled', function () { + config(['services.yandex_direct.enabled' => false]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания на паузу', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_RUNNING, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause"); + + $response->assertOk()->assertJsonPath('status', 'paused'); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $campaign->id, + 'status' => AdCampaign::STATUS_PAUSED, + ]); +}); + +/** + * 🪤 Мина со стороны «включено»: рубильник, заданный в .env строкой («1», «true»), + * приходит из env строкой. Проверка `!== true` считала бы такой рубильник ВЫКЛЮЧЕННЫМ и + * молча не шла в Директ, а пауза приняла бы это за успех и разморозила деньги — при том, + * что реклама в Яндексе продолжает крутиться. + */ +it('goes to Direct when the switch is on as a string', function () { + config(['services.yandex_direct.enabled' => '1']); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Рубильник строкой', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 555, + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/json/v5/campaigns')); +}); + +/** + * Пауза, которая не дошла до Директа, — это не пауза. + * + * Ошибку Директа портал глотал в журнал, ставил кампании статус «на паузе» и БЕЗУСЛОВНО + * размораживал деньги. Итог: реклама в Яндексе продолжает крутиться и тратить, портал + * показывает «на паузе», а деньги за неё уже свободны и могут уйти на другую кампанию. + * Клиент уходит в минус молча. + * + * Честнее отказать: «не удалось остановить, попробуйте ещё раз». + */ +it('refuses to pause and keeps the money frozen when Direct rejects the suspend', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + Http::fake(['*/json/v5/campaigns' => Http::response(['error' => ['error_string' => 'Директ лёг']], 500)]); + + app(AdWalletService::class)->topup($this->tenant->id, '5000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Пауза не дошла до Яндекса', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 555, + 'paid_impressions' => 10000, + 'client_cpm_rub' => '120.00', + ]); + + app(AdWalletService::class) + ->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '1200.00'); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause") + ->assertStatus(409); + + // Статус не сменился, деньги остались зарезервированными под работающую рекламу. + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING); + + $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('1200.00'); +}); + +it('pauses a pending_moderation campaign and calls Direct suspend when enabled', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 555]]]])]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания на модерации', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => 555, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause"); + + $response->assertOk()->assertJsonPath('status', 'paused'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/campaigns') + && ($request['method'] ?? null) === 'suspend' + && ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 555; + }); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $campaign->id, + 'status' => AdCampaign::STATUS_PAUSED, + ]); +}); + +it('rejects pausing a draft campaign with 409', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Черновик', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_DRAFT, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause"); + + $response->assertStatus(409) + ->assertJsonPath('message', 'Кампанию нельзя поставить на паузу из текущего состояния.'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +it('returns 404 pausing another tenant campaign', function () { + $tenantB = Tenant::factory()->create(); + $campaignB = AdCampaign::create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Чужая кампания', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_RUNNING, + ]); + + $this->postJson("/api/advertising/campaigns/{$campaignB->id}/pause")->assertStatus(404); +}); + +it('resumes a paused campaign and calls Direct resume when enabled', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['ResumeResults' => [['Id' => 777]]]])]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания на паузе', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_PAUSED, + 'yandex_campaign_id' => 777, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume"); + + $response->assertOk()->assertJsonPath('status', 'running'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/campaigns') + && ($request['method'] ?? null) === 'resume' + && ($request['params']['SelectionCriteria']['Ids'][0] ?? null) === 777; + }); + + $this->assertDatabaseHas('ad_campaigns', [ + 'id' => $campaign->id, + 'status' => AdCampaign::STATUS_RUNNING, + ]); +}); + +it('resumes a paused campaign without calling Direct when disabled', function () { + config(['services.yandex_direct.enabled' => false]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания без Директа', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_PAUSED, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume"); + + $response->assertOk()->assertJsonPath('status', 'running'); +}); + +it('rejects resuming a running campaign with 409', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Уже идёт', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_RUNNING, + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume"); + + $response->assertStatus(409) + ->assertJsonPath('message', 'Возобновить можно только кампанию на паузе.'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +it('returns 404 resuming another tenant campaign', function () { + $tenantB = Tenant::factory()->create(); + $campaignB = AdCampaign::create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Чужая кампания на паузе', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + 'status' => AdCampaign::STATUS_PAUSED, + ]); + + $this->postJson("/api/advertising/campaigns/{$campaignB->id}/resume")->assertStatus(404); +}); + +// ── ВЫХОД 4: пауза снимает заморозку, возобновление морозит остаток заново ── +// Решение владельца 27.07.2026: на паузе деньги клиента свободны — он может +// потратить их на другую рекламу; при возобновлении резервируем остаток сметы. + +it('ВЫХОД 4: пауза возвращает заморозку в свободные деньги', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(AdWalletService::class); + $svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания на паузу с заморозкой', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_RUNNING, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + // Остаток сметы: 10000 показов × 120 ₽/1000 = 1200 ₽, из них 200 ₽ уже списано. + $svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00'); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk(); + + expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); + +it('ВЫХОД 4: возобновление снова морозит неоткрученный остаток сметы', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(AdWalletService::class); + $svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания к возобновлению', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_PAUSED, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk(); + + expect(AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00'); +}); + +it('ВЫХОД 4: возобновление при нехватке денег отказывает понятно и оставляет паузу', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(AdWalletService::class); + $svc->topup($this->tenant->id, '500.00', 'yandex', 'тест'); // меньше остатка 1000 ₽ + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания без денег на возобновление', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_PAUSED, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume") + ->assertStatus(409) + ->assertJsonPath('message', 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_PAUSED); +}); + +// ШОВ: два теста выше проверяют половинки по отдельности — «пауза снимает заморозку» +// начинает с замороженной кампании, «возобновление снова морозит» начинает с кампании, +// которую НИКОГДА не морозили. Живая последовательность пауза → возобновление той же +// кампании не проверялась ни разу и падала дублем ключа брони. +it('ВЫХОД 4: пауза и следом возобновление той же кампании — клиент не видит ошибки', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(AdWalletService::class); + $svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания на паузу и обратно', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_RUNNING, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + $svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00'); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk(); + expect((string) AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00'); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk(); + expect((string) AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00'); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignPhonesUploadTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignPhonesUploadTest.php new file mode 100644 index 00000000..5d7a4a03 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingCampaignPhonesUploadTest.php @@ -0,0 +1,168 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); + + $this->campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания под список номеров', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); +}); + +it('recognizes valid phones from text, dedupes and skips invalid ones', function () { + $text = implode("\n", [ + '+7 900 000-00-01', + '79000000002', + '89000000003', + '9000000004', // 10 digits without leading 7/8 — valid RU mobile + '79000000002', // duplicate of line 2 + 'not-a-phone', // invalid + '123', // invalid + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'text' => $text, + ]); + + $response->assertOk() + ->assertJsonPath('recognized', 4) + ->assertJsonPath('skipped', 3); + + $this->assertDatabaseHas('ad_campaign_phones', [ + 'tenant_id' => $this->tenant->id, + 'campaign_id' => $this->campaign->id, + 'phone' => '79000000001', + ]); + $this->assertDatabaseHas('ad_campaign_phones', [ + 'campaign_id' => $this->campaign->id, + 'phone' => '79000000002', + ]); + $this->assertDatabaseHas('ad_campaign_phones', [ + 'campaign_id' => $this->campaign->id, + 'phone' => '79000000003', + ]); + $this->assertDatabaseHas('ad_campaign_phones', [ + 'campaign_id' => $this->campaign->id, + 'phone' => '79000000004', + ]); + + $count = DB::table('ad_campaign_phones')->where('campaign_id', $this->campaign->id)->count(); + expect($count)->toBe(4); +}); + +it('recognizes valid phones from an uploaded csv file', function () { + $content = implode("\n", [ + '+7 900 000-00-05', + '79000000006', + 'garbage', + ]); + $file = UploadedFile::fake()->createWithContent('phones.csv', $content); + + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'file' => $file, + ]); + + $response->assertOk() + ->assertJsonPath('recognized', 2) + ->assertJsonPath('skipped', 1); + + $this->assertDatabaseHas('ad_campaign_phones', [ + 'campaign_id' => $this->campaign->id, + 'phone' => '79000000005', + ]); + $this->assertDatabaseHas('ad_campaign_phones', [ + 'campaign_id' => $this->campaign->id, + 'phone' => '79000000006', + ]); +}); + +it('does not fail on repeated upload thanks to the unique constraint', function () { + $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'text' => '79000000007', + ])->assertOk(); + + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'text' => '79000000007', + ]); + + $response->assertOk()->assertJsonPath('recognized', 1); + + $count = DB::table('ad_campaign_phones') + ->where('campaign_id', $this->campaign->id) + ->where('phone', '79000000007') + ->count(); + expect($count)->toBe(1); +}); + +it('returns 422 when neither file nor text is provided ("тихий ноль")', function () { + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", []); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Загрузите файл или вставьте номера'); + + $count = DB::table('ad_campaign_phones')->where('campaign_id', $this->campaign->id)->count(); + expect($count)->toBe(0); +}); + +it('returns 422 when text is only whitespace and no file is provided', function () { + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'text' => ' ', + ]); + + $response->assertStatus(422) + ->assertJsonPath('message', 'Загрузите файл или вставьте номера'); +}); + +it('rejects a file with a disallowed extension', function () { + $file = UploadedFile::fake()->createWithContent('phones.pdf', '%PDF-1.4 not a real pdf'); + + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'file' => $file, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors('file'); +}); + +it('rejects a file larger than the 5 MB limit', function () { + $file = UploadedFile::fake()->create('phones.csv', 5121); // KB, чуть больше лимита + + $response = $this->postJson("/api/advertising/campaigns/{$this->campaign->id}/phones", [ + 'file' => $file, + ]); + + $response->assertStatus(422)->assertJsonValidationErrors('file'); +}); + +it('returns 404 when uploading phones for another tenants campaign', function () { + $tenantB = Tenant::factory()->create(); + $campaignB = AdCampaign::create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Чужая кампания', + 'audience_days' => 10, + 'weekly_budget_rub' => '1000.00', + ]); + + $response = $this->postJson("/api/advertising/campaigns/{$campaignB->id}/phones", [ + 'text' => '79000000008', + ]); + + $response->assertStatus(404); + $this->assertDatabaseMissing('ad_campaign_phones', ['campaign_id' => $campaignB->id]); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingScheduleTest.php b/app/tests/Feature/Advertising/AdvertisingScheduleTest.php new file mode 100644 index 00000000..9a6a2cd4 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingScheduleTest.php @@ -0,0 +1,28 @@ +events(); + $summaries = array_map(fn ($e) => $e->getSummaryForDisplay(), $events); + $joined = implode("\n", $summaries); + + expect($joined)->toContain('ChargeCampaignSpendJob') + ->and($joined)->toContain('SyncCampaignAudienceJob') + ->and($joined)->toContain('SyncCampaignModerationJob'); +}); + +/** + * Сторож очереди робота обязан быть в расписании. + * + * Сама команда без расписания бесполезна: зависшее «в работе» задание держит очередь для + * ВСЕХ клиентов, а разбирать пробку руками некому — робот работает по ночам без людей. + */ +it('registers the creative job reaper in the scheduler', function () { + $events = app(Schedule::class)->events(); + $joined = implode("\n", array_map(fn ($e) => $e->getSummaryForDisplay(), $events)); + + expect($joined)->toContain('creative-jobs:reap'); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php new file mode 100644 index 00000000..6e7fcc33 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingWalletEndpointTest.php @@ -0,0 +1,60 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); +}); + +it('returns solvent=false and free_rub when balance does not cover frozen', function () { + AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '2500.00']); + + $response = $this->getJson('/api/advertising/wallet'); + + $response->assertOk()->assertJson([ + 'solvent' => false, + 'balance_rub' => '100.00', + 'frozen_rub' => '2500.00', + 'free_rub' => '-2400.00', + ]); +}); + +it('returns solvent=true and free_rub when balance covers frozen', function () { + AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '3000.00', 'frozen_rub' => '2500.00']); + + $response = $this->getJson('/api/advertising/wallet'); + + $response->assertOk()->assertJson([ + 'solvent' => true, + 'balance_rub' => '3000.00', + 'frozen_rub' => '2500.00', + 'free_rub' => '500.00', + ]); +}); + +it('returns solvent=true and zero values when tenant has no wallet row yet', function () { + $response = $this->getJson('/api/advertising/wallet'); + + $response->assertOk()->assertJson([ + 'solvent' => true, + 'balance_rub' => '0.00', + 'frozen_rub' => '0.00', + 'free_rub' => '0.00', + ]); +}); + +it('returns 401 without auth', function () { + auth()->logout(); + + $this->getJson('/api/advertising/wallet')->assertStatus(401); +}); diff --git a/app/tests/Feature/Advertising/AudienceBuilderModeTest.php b/app/tests/Feature/Advertising/AudienceBuilderModeTest.php new file mode 100644 index 00000000..960409e3 --- /dev/null +++ b/app/tests/Feature/Advertising/AudienceBuilderModeTest.php @@ -0,0 +1,126 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Авто', + 'mode' => AdCampaign::MODE_AUTO, + 'audience_days' => 10, + 'use_uploaded_list' => true, + 'weekly_budget_rub' => '2500.00', + ]); + + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990001001', 'received_at' => now()->subDays(3)]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990001099', + 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), + ]); + + $phones = app(CampaignAudienceBuilder::class)->build($campaign); + + expect($phones)->toContain('79990001001') + ->and($phones)->not->toContain('79990001099'); +}); + +it('manual: сделка внутри диапазона снимка попадает, вне — нет', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Ручной', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'snapshot_from' => now()->subDays(5)->format('Y-m-d'), + 'snapshot_to' => now()->subDays(1)->format('Y-m-d'), + 'weekly_budget_rub' => '2500.00', + ]); + + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990002001', 'received_at' => now()->subDays(3)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990002002', 'received_at' => now()->subDays(20)]); + + $phones = app(CampaignAudienceBuilder::class)->build($campaign); + + expect($phones)->toContain('79990002001') + ->and($phones)->not->toContain('79990002002'); +}); + +it('manual: свой список подмешивается всегда (не по use_uploaded_list)', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Ручной без флага', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'snapshot_from' => now()->subDays(5)->format('Y-m-d'), + 'snapshot_to' => now()->subDays(1)->format('Y-m-d'), + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '2500.00', + ]); + + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990003001', + 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), + ]); + + $phones = app(CampaignAudienceBuilder::class)->build($campaign); + + expect($phones)->toContain('79990003001'); +}); + +it('manual: без заданных дат снимка сделки пустые, но список остаётся', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Ручной без дат', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'weekly_budget_rub' => '2500.00', + ]); + + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990004001', 'received_at' => now()->subDay()]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990004099', + 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), + ]); + + $phones = app(CampaignAudienceBuilder::class)->build($campaign); + + expect($phones)->not->toContain('79990004001') + ->and($phones)->toContain('79990004099'); +}); + +it('дедуп: один и тот же номер в сделке и в списке (manual) — один раз', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Дедуп', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'snapshot_from' => now()->subDays(5)->format('Y-m-d'), + 'snapshot_to' => now()->subDays(1)->format('Y-m-d'), + 'weekly_budget_rub' => '2500.00', + ]); + + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990005001', 'received_at' => now()->subDays(2)]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990005001', + 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), + ]); + + $phones = app(CampaignAudienceBuilder::class)->build($campaign); + + expect(array_count_values($phones)['79990005001'] ?? 0)->toBe(1); +}); diff --git a/app/tests/Feature/Advertising/BannerIncludedModelTest.php b/app/tests/Feature/Advertising/BannerIncludedModelTest.php new file mode 100644 index 00000000..80b489fd --- /dev/null +++ b/app/tests/Feature/Advertising/BannerIncludedModelTest.php @@ -0,0 +1,10 @@ + 0]); + expect($b->included)->toBeFalse(); +}); diff --git a/app/tests/Feature/Advertising/BannerYandexIdsTest.php b/app/tests/Feature/Advertising/BannerYandexIdsTest.php new file mode 100644 index 00000000..c458cb3b --- /dev/null +++ b/app/tests/Feature/Advertising/BannerYandexIdsTest.php @@ -0,0 +1,62 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => 'ad-banners/x/300x250.jpg', + 'bytes' => 1000, + 'yandex_creative_id' => 1163586673, + 'yandex_ad_id' => 17786692536, + 'moderation_status' => AdCampaignBanner::MOD_ACCEPTED, + 'moderation_reason' => null, + ]); + + $fresh = $banner->fresh(); + expect($fresh->yandex_creative_id)->toBe(1163586673) + ->and($fresh->yandex_ad_id)->toBe(17786692536) + ->and($fresh->moderation_status)->toBe('ACCEPTED'); +}); + +it('defaults moderation status to draft', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 728, + 'height' => 90, + 'path' => 'ad-banners/x/728x90.jpg', + 'bytes' => 1000, + ]); + + expect($banner->fresh()->moderation_status)->toBe('draft') + ->and($banner->fresh()->yandex_creative_id)->toBeNull(); +}); diff --git a/app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php b/app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php new file mode 100644 index 00000000..14971fdf --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignAudienceBuilderTest.php @@ -0,0 +1,60 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'snapshot_from' => now()->subDays(10)->format('Y-m-d'), + 'snapshot_to' => now()->format('Y-m-d'), + 'use_uploaded_list' => true, 'weekly_budget_rub' => '2500.00', + ]); + // сделки в окне и вне окна (RLS-контекст выставит билдер) + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'received_at' => now()->subDays(3)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'received_at' => now()->subDays(30)]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000003', + 'expires_at' => now()->addDays(5), 'created_at' => now(), 'updated_at' => now(), + ]); + + $phones = app(CampaignAudienceBuilder::class)->build($campaign); + + expect($phones)->toContain('79990000001') // в окне + ->and($phones)->not->toContain('79990000002') // вне окна + ->and($phones)->toContain('79990000003'); // свой список +}); + +it('counts audience size', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C2', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'snapshot_from' => now()->subDays(10)->format('Y-m-d'), + 'snapshot_to' => now()->format('Y-m-d'), + 'use_uploaded_list' => true, 'weekly_budget_rub' => '2500.00', + ]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000011', 'received_at' => now()->subDays(2)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000012', 'received_at' => now()->subDays(4)]); + // вне окна — не должна попасть в счётчик + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000013', 'received_at' => now()->subDays(30)]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'phone' => '79990000014', + 'expires_at' => null, 'created_at' => now(), 'updated_at' => now(), + ]); + + $builder = app(CampaignAudienceBuilder::class); + + expect($builder->size($campaign))->toBe(count($builder->build($campaign))) + ->and($builder->size($campaign))->toBe(3); +}); diff --git a/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php b/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php new file mode 100644 index 00000000..86ba20ec --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignBannerEndpointsTest.php @@ -0,0 +1,457 @@ +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]; +} + +/** + * Слот «кампания + размер» обязан быть один. Загрузка идёт через updateOrCreate, а он без + * уникального индекса не атомарен: два одновременных нажатия «загрузить» на один и тот же + * размер (медленная сеть, двойной клик, повтор с телефона) обе проверки «такой строки нет» + * проходят — и в базе оказываются ДВЕ строки одного слота. + * + * Дальше беда тихая: список слотов клиенту собирается через keyBy по размеру и одну строку + * молча теряет — в портале виден один баннер. А запуск идёт по ВСЕМ строкам и создаёт ДВА + * одинаковых объявления, которые крутятся за деньги клиента. + * + * Проверку в коде тут не поставить: она не блокирует, а гарантию даёт только база. + */ +it('база не даёт завести второй баннер того же размера в одной кампании', function () { + [$tenant, , $campaign] = bannerCampaign(); + + $slot = [ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'bytes' => 1000, + 'included' => true, + ]; + + AdCampaignBanner::create($slot + ['path' => 'ad-banners/a/300x250.jpg']); + + expect(fn () => AdCampaignBanner::create($slot + ['path' => 'ad-banners/b/300x250.jpg'])) + ->toThrow(QueryException::class); + + expect(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +it('список слотов без загрузок содержит все 15 размеров, все не загружены', function () { + [, $user, $campaign] = bannerCampaign(); + + $res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/banners"); + $res->assertOk(); + $res->assertJsonPath('approved_at', null); + $res->assertJsonPath('max_bytes', BannerUploadPolicy::MAX_BYTES); + expect($res->json('formats'))->toBe(BannerUploadPolicy::FORMATS); + + $slots = $res->json('slots'); + expect($slots)->toHaveCount(15); + foreach ($slots as $slot) { + expect($slot['uploaded'])->toBeFalse(); + expect($slot['banner_id'])->toBeNull(); + expect($slot['preview_url'])->toBeNull(); + } +}); + +it('загрузка баннера точного размера создаёт слот', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ]); + + $res->assertCreated(); + expect($res->json('slot.uploaded'))->toBeTrue(); + expect($res->json('slot.width'))->toBe(728); + expect($res->json('slot.height'))->toBe(90); + expect($res->json('slot.included'))->toBeTrue(); + + $banner = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->where('height', 90)->first(); + expect($banner)->not->toBeNull(); + Storage::disk('local')->assertExists($banner->path); + + expect($campaign->fresh()->banners_approved_at)->toBeNull(); +}); + +it('картинка не того размера — 422 с понятным сообщением', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 800, 100), + ]); + + $res->assertStatus(422); + expect($res->json('message'))->toContain('Нужен ровно 728×90'); +}); + +it('неизвестный размер — 422', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 999, 'height' => 999, + 'file' => UploadedFile::fake()->image('b.jpg', 999, 999), + ]); + + $res->assertStatus(422); + expect($res->json('message'))->toBe('Неизвестный размер баннера.'); +}); + +/** + * Предел веса картинки — 512 000 байт (BannerUploadPolicy::MAX_BYTES), это потолок Яндекса + * для графического креатива. Тяжелее — кабинет файл не примет, и узнаем мы об этом только + * тогда, когда робот уже стоит перед окном загрузки на боевом: он не умеет ни ужать картинку, + * ни спросить клиента. Задание уйдёт в сбой, кампания застрянет. + * + * 🪤 Прежние две проверки про этот предел были пустышками: одна сравнивала константу саму + * с собой, вторая — что то же число приезжает клиенту в JSON. Вырезание правила `max:` + * из валидации оставляло обе зелёными. Этот тест грузит настоящий перевес и проверен + * вырезанием: без правила `max:` он краснеет. + */ +it('картинка тяжелее предела отклоняется', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $tooHeavy = UploadedFile::fake()->image('b.jpg', 728, 90)->size((int) (BannerUploadPolicy::MAX_BYTES / 1024) + 1); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => $tooHeavy, + ]); + + $res->assertStatus(422); + $res->assertJsonValidationErrors('file'); + expect(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +it('не-картинка отклоняется валидатором', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->create('b.txt', 5, 'text/plain'), + ]); + + $res->assertStatus(422); +}); + +it('повторная загрузка того же размера заменяет строку, а не дублирует', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b1.jpg', 728, 90), + ])->assertCreated(); + + $campaign->update(['banners_approved_at' => now()]); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b2.png', 728, 90), + ]); + $res->assertCreated(); + + expect(AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->where('height', 90)->count())->toBe(1); + expect($campaign->fresh()->banners_approved_at)->toBeNull(); +}); + +it('переключает included баннера', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ])->assertCreated(); + $bannerId = $up->json('slot.banner_id'); + + $res = $this->actingAs($user)->patchJson("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}", [ + 'included' => false, + ]); + $res->assertOk(); + expect($res->json('slot.included'))->toBeFalse(); + + $banner = AdCampaignBanner::find($bannerId); + expect($banner->included)->toBeFalse(); +}); + +/** + * Робот успел отвезти креативы в кабинет (номер проставлен), клиент передумал и перезалил + * картинку. Кампании в Директе ещё нет, поэтому замок на баннерах не срабатывает — и это + * правильно, менять набор до запуска можно. Но номер креатива остаётся от СТАРОЙ картинки: + * в портале клиент видит новую, а запуск создаст объявление по прежней, и клиент заплатит + * за показы того, что уже забраковал. Номер обязан обнулиться вместе с картинкой. + */ +it('перезаливка картинки сбрасывает номер креатива Яндекса', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ])->assertCreated(); + $bannerId = $up->json('slot.banner_id'); + + // Робот отработал: номер креатива у баннера есть. + AdCampaignBanner::where('id', $bannerId)->update(['yandex_creative_id' => 4242]); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('new.jpg', 728, 90), + ])->assertCreated(); + + expect(AdCampaignBanner::find($bannerId)->yandex_creative_id)->toBeNull(); +}); + +it('удаляет баннер и сбрасывает утверждение', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ])->assertCreated(); + $bannerId = $up->json('slot.banner_id'); + $campaign->update(['banners_approved_at' => now()]); + + $res = $this->actingAs($user)->deleteJson("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}"); + $res->assertStatus(204); + + expect(AdCampaignBanner::find($bannerId))->toBeNull(); + expect($campaign->fresh()->banners_approved_at)->toBeNull(); +}); + +it('утверждает при хотя бы одном included баннере', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ])->assertCreated(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners/approve"); + $res->assertOk(); + expect($campaign->fresh()->banners_approved_at)->not->toBeNull(); +}); + +it('не утверждает, если все included=false', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ])->assertCreated(); + $bannerId = $up->json('slot.banner_id'); + $this->actingAs($user)->patchJson("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}", ['included' => false])->assertOk(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners/approve"); + $res->assertStatus(422); + expect($res->json('message'))->toBe('Отметьте хотя бы один баннер для показа.'); +}); + +it('нельзя утвердить пустой набор', function () { + [, $user, $campaign] = bannerCampaign(); + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners/approve")->assertStatus(422); +}); + +it('превью работает и отдаёт content-type по расширению', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + + $up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.png', 728, 90), + ])->assertCreated(); + $bannerId = $up->json('slot.banner_id'); + + $preview = $this->actingAs($user)->get("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}/preview"); + $preview->assertOk(); + expect($preview->headers->get('content-type'))->toContain('image/png'); +}); + +it('чужой тенант не видит превью баннера (404)', function () { + Storage::fake('local'); + [, $user, $campaign] = bannerCampaign(); + $up = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('b.jpg', 728, 90), + ])->assertCreated(); + $bannerId = $up->json('slot.banner_id'); + + $otherTenant = Tenant::factory()->create(); + $otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]); + $this->actingAs($otherUser)->get("/api/advertising/campaigns/{$campaign->id}/banners/{$bannerId}/preview")->assertNotFound(); +}); + +it('размеры вне BannerSizes перечислены целиком и по порядку', function () { + [, $user, $campaign] = bannerCampaign(); + $res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/banners"); + $slots = $res->json('slots'); + foreach (BannerSizes::all() as $i => [$w, $h]) { + expect($slots[$i]['width'])->toBe($w); + expect($slots[$i]['height'])->toBe($h); + } +}); + +/* + * ЗАМОК НА БАННЕРАХ ПОСЛЕ ЗАВЕДЕНИЯ КАМПАНИИ В ЯНДЕКСЕ. + * + * Тот же замок, что уже стоит на правке параметров кампании, и по тому же признаку — + * `yandex_campaign_id !== null`. Не по статусу: оборвавшийся запуск оставляет статус `draft`, + * а кампания, группа и часть объявлений в кабинете уже созданы, и по статусу такую кампанию + * от нетронутого черновика не отличить. + * + * Что ломалось без замка: + * - перезаливка картинки: строка баннера обновляется, а `yandex_creative_id`/`yandex_ad_id` + * остаются от старого креатива — в портале новая картинка, в Яндексе крутится старая, молча; + * - удаление и повторная заливка: строка с `yandex_ad_id` уничтожается, и возобновляемый + * запуск видит «номера объявления нет» и создаёт ВТОРОЕ объявление того же размера. + * Старое остаётся крутиться — это живые деньги клиента. + */ + +function launchedBannerCampaign(): array +{ + [$tenant, $user, $campaign] = bannerCampaign(); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 728, 'height' => 90, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/728x90.jpg", + 'bytes' => 1000, 'included' => true, + ]); + + $campaign->update(['yandex_campaign_id' => 713051718]); + + return [$user, $campaign, $banner]; +} + +it('перезалить баннер после заведения кампании в Яндексе нельзя', function () { + Storage::fake('local'); + [$user, $campaign, $banner] = launchedBannerCampaign(); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('new.jpg', 728, 90), + ]); + + $res->assertStatus(409); + expect($res->json('message'))->toContain('уже заведена в Яндексе'); + expect($banner->fresh()->path)->toBe("ad-banners/{$campaign->tenant_id}/{$campaign->id}/728x90.jpg"); +}); + +it('включить или выключить баннер после заведения кампании в Яндексе нельзя', function () { + [$user, $campaign, $banner] = launchedBannerCampaign(); + + $res = $this->actingAs($user)->patchJson( + "/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}", + ['included' => false], + ); + + $res->assertStatus(409); + expect($banner->fresh()->included)->toBeTrue(); +}); + +it('удалить баннер после заведения кампании в Яндексе нельзя', function () { + Storage::fake('local'); + [$user, $campaign, $banner] = launchedBannerCampaign(); + + $this->actingAs($user) + ->deleteJson("/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}") + ->assertStatus(409); + + expect(AdCampaignBanner::find($banner->id))->not->toBeNull(); +}); + +/** + * Отдельная, более жёсткая защита: у баннера есть номер объявления в Яндексе — значит + * объявление создано и крутится. Удалять такую строку нельзя НИКОГДА, даже если номер + * кампании почему-то пуст: без строки портал забудет про живое объявление, а возобновление + * заведёт второе. + */ +it('баннер с номером объявления в Яндексе не удаляется даже без номера кампании', function () { + Storage::fake('local'); + [$tenant, $user, $campaign] = bannerCampaign(); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 728, 'height' => 90, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/728x90.jpg", + 'bytes' => 1000, 'included' => true, 'yandex_ad_id' => 42424242, + ]); + + $this->actingAs($user) + ->deleteJson("/api/advertising/campaigns/{$campaign->id}/banners/{$banner->id}") + ->assertStatus(409); + + expect(AdCampaignBanner::find($banner->id))->not->toBeNull(); +}); + +/** + * Узкое исключение из замка: у отклонённой кампании показов нет и денег на ней нет, + * поэтому картинку клиент обязан иметь возможность переделать. Соседний тест + * «перезалить баннер после заведения кампании в Яндексе нельзя» держит вторую половину + * правила: у НЕотклонённой кампании замок остаётся на месте. + */ +it('у отклонённой кампании картинку перезалить можно', function () { + Storage::fake('local'); + [$user, $campaign, $banner] = launchedBannerCampaign(); + $campaign->update(['status' => AdCampaign::STATUS_REJECTED]); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('new.jpg', 728, 90), + ]); + + $res->assertStatus(201); + expect($banner->fresh()->bytes)->not->toBe(1000); +}); + +/** + * Вторая половина той же живой находки: после «Исправить» клиенту нужна новая картинка, + * а замок на баннерах смотрел на номер кампании и запирал перезаливку насовсем. + */ +it('после оживления картинку перезалить можно', function () { + Storage::fake('local'); + [$user, $campaign, $banner] = launchedBannerCampaign(); + $campaign->update(['status' => AdCampaign::STATUS_REJECTED]); + $banner->update(['moderation_status' => AdCampaignBanner::MOD_REJECTED, 'yandex_ad_id' => 200]); + + config(['services.yandex_direct.enabled' => false]); + app(CampaignReviveService::class)->revive($campaign); + + $res = $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/banners", [ + 'width' => 728, 'height' => 90, + 'file' => UploadedFile::fake()->image('new.jpg', 728, 90), + ]); + + $res->assertStatus(201); +}); diff --git a/app/tests/Feature/Advertising/CampaignBannerServiceTest.php b/app/tests/Feature/Advertising/CampaignBannerServiceTest.php new file mode 100644 index 00000000..36cc44f1 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignBannerServiceTest.php @@ -0,0 +1,57 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + + $count = app(CampaignBannerService::class)->generate($campaign, bannerSrcJpeg(1500, 1500)); + + expect($count)->toBe(15) + ->and(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(15); + + $b = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 300)->where('height', 250)->first(); + expect($b)->not->toBeNull(); + Storage::disk('local')->assertExists($b->path); + $info = getimagesizefromstring((string) Storage::disk('local')->get($b->path)); + expect($info[0])->toBe(300)->and($info[1])->toBe(250)->and($b->bytes)->toBeGreaterThan(0); +}); + +it('перегенерация заменяет прежний набор (снова 15, старые файлы удалены)', function () { + Storage::fake('local'); + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + $svc = app(CampaignBannerService::class); + + $svc->generate($campaign, bannerSrcJpeg(1500, 1500)); + $firstPaths = AdCampaignBanner::where('campaign_id', $campaign->id)->pluck('path')->all(); + + $svc->generate($campaign, bannerSrcJpeg(1200, 1200)); + + expect(AdCampaignBanner::where('campaign_id', $campaign->id)->count())->toBe(15); + expect(count($firstPaths))->toBe(15); +}); diff --git a/app/tests/Feature/Advertising/CampaignEstimateEndpointTest.php b/app/tests/Feature/Advertising/CampaignEstimateEndpointTest.php new file mode 100644 index 00000000..0163caf2 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignEstimateEndpointTest.php @@ -0,0 +1,78 @@ +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, + ]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000001', 'received_at' => now()->subDays(2)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990000002', 'received_at' => now()->subDays(3)]); + + $res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10&frequency=10"); + + $res->assertOk() + ->assertJsonPath('size', 2) + ->assertJsonPath('impressions', 20) + ->assertJsonPath('cpm_rub', '120.00') + ->assertJsonPath('cost_rub', '2.40') + ->assertJsonPath('enough', false); +}); + +it('без частоты endpoint отдаёт прежний ответ без сметы', 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, + ]); + + $res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?days=10"); + + $res->assertOk()->assertJsonPath('size', 0)->assertJsonMissingPath('cost_rub'); +}); + +it('T4b: manual+from/to возвращает размер по диапазону снимка', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_AUTO, + 'audience_days' => 10, 'use_uploaded_list' => false, + ]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990010001', 'received_at' => now()->subDays(3)]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990010002', 'received_at' => now()->subDays(20)]); + + $from = now()->subDays(5)->format('Y-m-d'); + $to = now()->subDays(1)->format('Y-m-d'); + + $res = $this->actingAs($user)->getJson( + "/api/advertising/campaigns/{$campaign->id}/audience-size?mode=manual&from={$from}&to={$to}" + ); + + $res->assertOk()->assertJsonPath('size', 1); +}); + +it('T4b: передача cpm=90 в смету считает стоимость по 90, не по дефолту ad_settings', 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, + ]); + Deal::factory()->create(['tenant_id' => $tenant->id, 'phone' => '79990011001', 'received_at' => now()->subDays(2)]); + + $res = $this->actingAs($user)->getJson( + "/api/advertising/campaigns/{$campaign->id}/audience-size?days=10&frequency=10&cpm=90" + ); + + $res->assertOk() + ->assertJsonPath('size', 1) + ->assertJsonPath('impressions', 10) + ->assertJsonPath('cpm_rub', '90.00') + ->assertJsonPath('cost_rub', '0.90'); +}); diff --git a/app/tests/Feature/Advertising/CampaignEstimateServiceTest.php b/app/tests/Feature/Advertising/CampaignEstimateServiceTest.php new file mode 100644 index 00000000..5ffa5eb4 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignEstimateServiceTest.php @@ -0,0 +1,49 @@ +estimate(size: 1630, frequency: 15); + expect($est['size'])->toBe(1630) + ->and($est['frequency'])->toBe(15) + ->and($est['impressions'])->toBe(24450) + ->and($est['cpm_rub'])->toBe('120.00') + ->and($est['cost_rub'])->toBe('2934.00') + ->and($est['min'])->toBe(100) + ->and($est['enough'])->toBeTrue(); +}); + +it('помечает enough=false при аудитории меньше 100', function () { + $svc = app(CampaignEstimateService::class); + $est = $svc->estimate(size: 50, frequency: 10); + expect($est['enough'])->toBeFalse()->and($est['impressions'])->toBe(500); +}); + +it('уважает изменённую цену client_cpm_rub', function () { + DB::table('ad_settings')->update(['client_cpm_rub' => '200.00']); + $svc = app(CampaignEstimateService::class); + $est = $svc->estimate(size: 1000, frequency: 10); + expect($est['cpm_rub'])->toBe('200.00')->and($est['cost_rub'])->toBe('2000.00'); +}); + +it('T4: при переданной цене кампании cpm считает по ней, а не по ad_settings', function () { + $svc = app(CampaignEstimateService::class); + $est = $svc->estimate(size: 1000, frequency: 10, cpm: '90.00'); + expect($est['cpm_rub'])->toBe('90.00')->and($est['cost_rub'])->toBe('900.00'); +}); + +it('T4: без переданной цены — прежнее поведение, дефолт ad_settings', function () { + $svc = app(CampaignEstimateService::class); + $est = $svc->estimate(size: 1000, frequency: 10, cpm: null); + expect($est['cpm_rub'])->toBe('120.00')->and($est['cost_rub'])->toBe('1200.00'); +}); diff --git a/app/tests/Feature/Advertising/CampaignImpressionChargerTest.php b/app/tests/Feature/Advertising/CampaignImpressionChargerTest.php new file mode 100644 index 00000000..d0f71821 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignImpressionChargerTest.php @@ -0,0 +1,233 @@ + $tenantId, + 'name' => 'Кампания за показы', + 'status' => AdCampaign::STATUS_RUNNING, + 'audience_days' => 30, + 'use_uploaded_list' => true, + 'frequency' => 15, + 'estimated_impressions' => $paidImpressions, + 'paid_impressions' => $paidImpressions, + 'client_cpm_rub' => $clientCpmRub, + ]); +} + +it('charges the client for actually delivered impressions', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + + app(CampaignImpressionCharger::class)->charge($campaign, 2500); + + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('300.00') + ->and($campaign->delivered_impressions)->toBe(2500) + ->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('9700.00'); + + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1); +}); + +it('does not double-charge when called again with the same delivered count', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + + $charger = app(CampaignImpressionCharger::class); + $charger->charge($campaign, 2500); + $charger->charge($campaign->refresh(), 2500); + + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('300.00'); + + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1); +}); + +it('charges only the delta on a later top-up call', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + + $charger = app(CampaignImpressionCharger::class); + $charger->charge($campaign, 2500); + $charger->charge($campaign->refresh(), 6000); + + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('720.00') + ->and($campaign->delivered_impressions)->toBe(6000); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('9280.00'); // 10000 − 720 + + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(2); +}); + +it('marks the campaign completed once delivered reaches the paid amount', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 5000); + + app(CampaignImpressionCharger::class)->charge($campaign, 5000); + + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('600.00') + ->and($campaign->delivered_impressions)->toBe(5000) + ->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED); +}); + +it('caps billable impressions at the paid amount when delivered overshoots', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 5000); + + app(CampaignImpressionCharger::class)->charge($campaign, 8000); + + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('600.00') + ->and($campaign->delivered_impressions)->toBe(8000) + ->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('9400.00'); // 10000 − 600 +}); + +it('charges by the campaign own CPM when client_cpm_rub is set, not the global default', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000, '90.00'); + + app(CampaignImpressionCharger::class)->charge($campaign, 2500); + + $campaign->refresh(); + // 2500 показов × 90.00₽/1000 = 225.00 (не 300.00, как было бы по глобальной цене 120.00). + expect($campaign->charged_client_rub)->toBe('225.00'); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('9775.00'); +}); + +it('records our margin-adjusted yandex cost alongside the client charge (40% margin default)', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + + app(CampaignImpressionCharger::class)->charge($campaign, 2500); + + $campaign->refresh(); + // charged_client_rub = 300.00 (дефолт 120.00₽/1000), margin 40% → yandex_cost_rub = 60% от 300.00 = 180.00. + expect($campaign->charged_client_rub)->toBe('300.00') + ->and(DB::table('ad_campaigns')->where('id', $campaign->id)->value('yandex_cost_rub'))->toBe('180.00'); +}); + +it('keeps charged_client_rub monotonic when a later report returns fewer impressions', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + + $charger = app(CampaignImpressionCharger::class); + $charger->charge($campaign, 6000); // 6000 × 120/1000 = 720.00 + $charger->charge($campaign->refresh(), 2500); // отчёт «просел» до 2500 — база НЕ опускается + + $campaign->refresh(); + // charged_client_rub держится на high-water 720.00, а не откатывается к 300.00. + expect($campaign->charged_client_rub)->toBe('720.00') + ->and($campaign->delivered_impressions)->toBe(2500); // метрика показов отражает факт + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('9280.00'); // 10000 − 720, повторно не списали + + // Повторный рост до 6000 не даёт двойного списания (external_key уже был). + $charger->charge($campaign->refresh(), 6000); + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('720.00'); + expect(AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('9280.00'); +}); + +/** + * ВЫХОД 1, второе условие — срок показа истёк. + * + * Медийка по списку телефонов почти всегда НЕ добирает смету: аудитория ограничена, + * частота показов ограничена. Без этого условия статус вечно оставался бы `running`, + * а заморозка — ACTIVE навсегда: деньги клиента заперты в кампании, которая уже + * ничего не показывает (EndDate в Директе прошёл, delivered замер). + */ +it('completes an under-delivered campaign once its show period has ended and releases the hold', function () { + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + $campaign->update(['shows_until' => now()->subDay()->toDateString()]); + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + app(CampaignImpressionCharger::class)->charge($campaign, 2500); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED) + ->and($campaign->charged_client_rub)->toBe('300.00'); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00') // остаток сметы разморожен + ->and($wallet->balance_rub)->toBe('9700.00'); // списано только за фактические показы +}); + +it('keeps the campaign running while its show period is still open', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + $campaign->update(['shows_until' => now()->addDay()->toDateString()]); + + app(CampaignImpressionCharger::class)->charge($campaign, 2500); + + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +it('keeps the campaign running on the last day of the show period', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 10000); + $campaign->update(['shows_until' => now()->toDateString()]); // последний день — ещё крутится + + app(CampaignImpressionCharger::class)->charge($campaign, 2500); + + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +it('charges nothing for zero delivered impressions', function () { + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makePokazyCampaign($tenant->id, 5000); + + app(CampaignImpressionCharger::class)->charge($campaign, 0); + + $campaign->refresh(); + expect($campaign->charged_client_rub)->toBe('0.00') + ->and($campaign->delivered_impressions)->toBe(0) + ->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('10000.00'); + + expect(AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0); +}); diff --git a/app/tests/Feature/Advertising/CampaignLauncherTest.php b/app/tests/Feature/Advertising/CampaignLauncherTest.php new file mode 100644 index 00000000..59b9db64 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignLauncherTest.php @@ -0,0 +1,1058 @@ + Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), + '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), + ]); +} + +/** + * Настоящие размеры креативов, которые раздаёт seedBanners(): запуск сверяет их + * с размерами баннеров перед созданием объявлений. Возвращаем весь известный набор — + * лишние номера в ответе безвредны, сверка идёт по номеру. + * + * @return list> + */ +function creativesOfSeedBanners(): array +{ + $out = []; + foreach ([4242 => [300, 250], 4243 => [728, 90], 4244 => [970, 250]] as $id => [$w, $h]) { + $out[] = ['Id' => $id, 'Type' => 'HTML5_CREATIVE', 'Width' => $w, 'Height' => $h]; + } + + return $out; +} + +function configureYandex(): void +{ + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); + config(['services.yandex_audience.token' => 'AUDTOKEN']); +} + +/** Наполняет ad_campaign_phones $count уникальными номерами для кампании (обходит фабрику Deal — быстрее). */ +function seedAudience(AdCampaign $campaign, int $count): void +{ + $rows = []; + for ($i = 0; $i < $count; $i++) { + $rows[] = [ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'phone' => sprintf('799900%05d', $i), + 'expires_at' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + DB::table('ad_campaign_phones')->insert($rows); +} + +/** Кладёт кампании набор баннеров с номерами креативов, как будто робот уже отработал. */ +function seedBanners(AdCampaign $campaign, array $sizesToCreativeId): void +{ + foreach ($sizesToCreativeId as $size => $creativeId) { + [$w, $h] = array_map('intval', explode('x', (string) $size)); + AdCampaignBanner::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => $h, + 'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$size}.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => $creativeId, + ]); + } +} + +/** Кампания «за показы» в режиме manual с адресом сайта; креативы — на баннерах набора. */ +function makeImpressionCampaign(int $tenantId, array $overrides = []): AdCampaign +{ + return AdCampaign::create(array_merge([ + 'tenant_id' => $tenantId, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'use_uploaded_list' => true, + 'estimated_impressions' => 100000, + 'frequency' => 3, + 'frequency_period_days' => 7, + 'client_cpm_rub' => '120.00', + // Конструктора креативов у Яндекса больше нет: поле кампании — аварийный ручной + // путь, запуск его не читает. Номера креативов живут на баннерах набора. + 'yandex_creative_id' => null, + 'landing_url' => 'https://liderra.ru/promo', + 'run_days' => 14, + ], $overrides)); +} + +it('launches an impression campaign: segment → Direct CPM → freeze budget → pending moderation', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) + ->and($campaign->yandex_segment_id)->toBe(900001) + ->and($campaign->yandex_retargeting_list_id)->toBe(111) + ->and($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->yandex_ad_group_id)->toBe(333) + ->and($campaign->paid_impressions)->toBe(100000) // смета = потолок биллинга (Задача 10 fix) + ->and($campaign->launched_at)->not->toBeNull(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('12000.00'); // 100000 показов × 120.00₽/1000 + + $hold = AdWalletHold::where('tenant_id', $tenant->id) + ->where('channel', 'yandex')->where('source_type', 'campaign') + ->where('source_id', $campaign->id)->where('status', AdWalletHold::STATUS_ACTIVE)->first(); + expect($hold)->not->toBeNull() + ->and($hold->amount_rub)->toBe('12000.00'); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/json/v5/campaigns')) { + return false; + } + + $cpmStrategy = $request['params']['Campaigns'][0]['CpmBannerCampaign']['BiddingStrategy'] ?? null; + $freqCap = $request['params']['Campaigns'][0]['CpmBannerCampaign']['FrequencyCap'] ?? null; + + // margin по умолчанию 40% (ad_settings.ad_margin_percent), client_cpm 120.00 → + // yandex cpm = 72.00₽/1000 → AverageCpm = 72 000 000 микросов. + return ($cpmStrategy['Network']['CpMaximumImpressions']['AverageCpm'] ?? null) === 72000000 + && ($cpmStrategy['Network']['CpMaximumImpressions']['SpendLimit'] ?? null) === 8640000000 + && ($cpmStrategy['Search']['BiddingStrategyType'] ?? null) === 'SERVING_OFF' + && ($cpmStrategy['Network']['BiddingStrategyType'] ?? null) === 'CP_MAXIMUM_IMPRESSIONS' + && ($freqCap['Impressions'] ?? null) === 3 + && ($freqCap['PeriodDays'] ?? null) === 7; + }); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/json/v5/ads')) { + return false; + } + + $ad = $request['params']['Ads'][0]['CpmBannerAdBuilderAd'] ?? null; + + return ($ad['Creative']['CreativeId'] ?? null) === 4242 + && ($ad['Href'] ?? null) === 'https://liderra.ru/promo'; + }); +}); + +/** + * Номер креатива до сих пор брался на веру. А попасть в баннер он может двумя путями: + * от робота (опознание по разнице слепков) и руками оператора. Ошибка в любом из них — + * это объявление 300×250, в котором крутится картинка 728×90: клиент платит за показы + * чужого или битого баннера, и заметить это можно только глазами в кабинете. + * + * Спрашиваем у Яндекса настоящий размер каждого креатива ОДНИМ запросом и сверяем + * с размером баннера. Не сошлось — не запускаемся вообще. + */ +it('refuses to launch when the creative size does not match the banner size', function () { + configureYandex(); + Http::fake([ + '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + // Креатив 4242 на самом деле 728×90, а висит на баннере 300×250. + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), + ]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'размер'); + + // Объявление не создано, деньги не заморожены, кампания вернулась в черновик. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); +}); + +/** + * Тот же класс беды с другой стороны: номер вписан, но такого креатива в кабинете нет + * вовсе (удалили, опечатались в цифре, номер из чужого аккаунта). Молча создать + * объявление по несуществующему креативу нельзя. + */ +it('refuses to launch when the creative number is not found in the cabinet', function () { + configureYandex(); + Http::fake([ + '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]]), + '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), + ]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, '4242'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +/** + * Цена, по которой заморозили деньги, обязана остаться НА КАМПАНИИ. + * + * Кампания могла быть заведена без своей цены — тогда `effectiveCpm()` читает глобальную + * из настроек рекламы. Заморозка считается по ней и записывается в кошелёк, а вот на самой + * кампании цена не оставалась. Админ менял глобальную цену — и суточное списание шло уже + * по НОВОЙ, выше замороженной: клиент платил больше, чем ему обещали при запуске. + */ +it('records the client price the money was frozen at', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + // Своей цены у кампании нет — берётся глобальная из ad_settings. + $campaign = makeImpressionCampaign($tenant->id, ['client_cpm_rub' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + $globalCpm = $campaign->effectiveCpm(); + + app(CampaignLauncher::class)->launch($campaign); + + expect($campaign->fresh()->client_cpm_rub)->toBe($globalCpm); + + // Админ поменял глобальную цену — запущенная кампания обязана остаться на своей. + DB::table('ad_settings')->update(['client_cpm_rub' => '999.00']); + expect($campaign->fresh()->effectiveCpm())->toBe($globalCpm); +}); + +it('throws AudienceTooSmallException and does not freeze when audience is under 100', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 50); + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(AudienceTooSmallException::class); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); + + $holdExists = AdWalletHold::where('tenant_id', $tenant->id) + ->where('source_type', 'campaign')->where('source_id', $campaign->id) + ->where('status', AdWalletHold::STATUS_ACTIVE)->exists(); + expect($holdExists)->toBeFalse(); +}); + +it('throws RuntimeException when yandex_direct is disabled and creates nothing', function () { + config(['services.yandex_direct.enabled' => false]); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + $campaign = makeImpressionCampaign($tenant->id, ['use_uploaded_list' => false]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT) + ->and($campaign->yandex_segment_id)->toBeNull(); + + Http::assertNothingSent(); +}); + +it('creates one ad per included banner and stores the ad id on each banner', function () { + configureYandex(); + Http::fake([ + '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), + // Каждый вызов ads.add отдаёт свой номер объявления. + '*/json/v5/ads' => Http::sequence() + ->push(['result' => ['AddResults' => [['Id' => 5551]]]]) + ->push(['result' => ['AddResults' => [['Id' => 5552]]]]), + ]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + + app(CampaignLauncher::class)->launch($campaign); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners)->toHaveCount(2) + ->and($banners[0]->yandex_ad_id)->toBe(5551) + ->and($banners[0]->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION) + ->and($banners[1]->yandex_ad_id)->toBe(5552); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); + + // 2 запроса на сегмент Аудиторий (заливка csv + подтверждение) + retargetinglists + + // campaigns + adgroups + audiencetargets + один creatives.get на сверку размеров всего + // набора + по одному ads.add на каждый баннер (2) = 9. + Http::assertSentCount(9); +}); + +it('skips banners that are switched off by the client', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->update(['included' => false]); + + app(CampaignLauncher::class)->launch($campaign); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/ads') + && ($request['params']['Ads'][0]['CpmBannerAdBuilderAd']['Creative']['CreativeId'] ?? null) === 4242; + }); + + $off = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->first(); + expect($off->yandex_ad_id)->toBeNull(); +}); + +it('refuses to launch when a included banner has no creative number yet', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + AdCampaignBanner::where('campaign_id', $campaign->id)->update(['yandex_creative_id' => null]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'креатив'); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); + Http::assertNothingSent(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); +}); + +it('refuses to launch when the campaign has no banners at all', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'баннер'); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); + Http::assertNothingSent(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); +}); + +it('throws RuntimeException with a clear message when landing_url is missing', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['landing_url' => null]); + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'адрес сайта'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); + + Http::assertNothingSent(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); +}); + +it('throws RuntimeException with a clear message when estimated_impressions is not set', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => null]); + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'смета показов'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); + + Http::assertNothingSent(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); +}); + +it('resumes an interrupted launch: no second Yandex campaign, no duplicated ads', function () { + configureYandex(); + + // Один фейк на весь тест с состоянием: считаем обращения по адресам и умеем «оборвать + // связь» на втором объявлении. Повторный Http::fake() сбросил бы счётчики Laravel, но + // старые заглушки остаются в силе (они мержатся, а не заменяются) — поэтому счёт свой. + $calls = []; + $breakAds = true; + $adsSeen = 0; + + Http::fake(function ($request) use (&$calls, &$breakAds, &$adsSeen) { + $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) { + 'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]), + 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), + 'adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + 'audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + 'ads' => (++$adsSeen === 2 && $breakAds) + ? Http::response([], 500) // обрыв связи на втором объявлении из трёх + : Http::response(['result' => ['AddResults' => [['Id' => 5550 + $adsSeen]]]]), + 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, '728x90' => 4243, '970x250' => 4244]); + + // 1) Первый запуск обрывается на втором объявлении. + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class); + + // Всё, что уже создано в кабинете Яндекса, должно быть записано на кампанию СРАЗУ — + // иначе портал «забудет» о ней и повторный запуск заведёт вторую кампанию-сироту. + $campaign->refresh(); + expect($campaign->yandex_segment_id)->toBe(900001) + ->and($campaign->yandex_retargeting_list_id)->toBe(111) + ->and($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->yandex_ad_group_id)->toBe(333) + ->and($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); // запуск не завершён + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners[0]->yandex_ad_id)->toBe(5551) + ->and($banners[1]->yandex_ad_id)->toBeNull() + ->and($banners[2]->yandex_ad_id)->toBeNull(); + + // 2) Повторный запуск — связь восстановилась. + $calls = []; + $breakAds = false; + + 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(2); // только два баннера без номера объявления + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) + ->and($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->yandex_ad_group_id)->toBe(333); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners[0]->yandex_ad_id)->toBe(5551) // не перезаписан + ->and($banners[1]->yandex_ad_id)->toBe(5553) + ->and($banners[2]->yandex_ad_id)->toBe(5554); +}); + +it('re-creates the ad group when the launch broke between the group and the audience target', function () { + configureYandex(); + + // Тот же приём, что и в тесте про обрыв на объявлениях: одно фейк-замыкание с + // состоянием, счёт обращений — свой (повторный Http::fake() сбросил бы счётчики + // Laravel, но старые заглушки остались бы в силе). + $calls = []; + $breakTarget = true; + + Http::fake(function ($request) use (&$calls, &$breakTarget) { + $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) { + 'segment_upload', 'segment_confirm' => Http::response(['segment' => ['id' => 900001]]), + 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), + 'adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + 'audiencetargets' => $breakTarget + ? Http::response([], 500) // обрыв ровно между группой и аудиторией + : Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + 'ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), + 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]); + + // 1) Первый запуск обрывается на навешивании аудитории. + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class); + + // Номер группы — отметка «группа готова ЦЕЛИКОМ» (создана И аудитория привязана). + // Таргет не встал ⇒ номер писать нельзя, иначе повторный запуск переиспользует + // группу без аудитории и кампания молча никогда не заработает. + $campaign->refresh(); + expect($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->yandex_retargeting_list_id)->toBe(111) + ->and($campaign->yandex_ad_group_id)->toBeNull() + ->and($campaign->status)->toBe(AdCampaign::STATUS_DRAFT); + + // 2) Повторный запуск — связь восстановилась. + $calls = []; + $breakTarget = false; + + app(CampaignLauncher::class)->launch($campaign); + + expect($calls['adgroups'] ?? 0)->toBe(1) // группа заводится заново + ->and($calls['audiencetargets'] ?? 0)->toBe(1) // и аудитория к ней привязывается + ->and($calls)->not->toHaveKey('segment_upload') + ->and($calls)->not->toHaveKey('retargetinglists') + ->and($calls)->not->toHaveKey('campaigns'); + + $campaign->refresh(); + expect($campaign->yandex_ad_group_id)->toBe(333) + ->and($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); + + $banner = AdCampaignBanner::where('campaign_id', $campaign->id)->first(); + expect($banner->yandex_ad_id)->toBe(555); +}); + +it('refuses to launch a campaign that is already running and touches neither Yandex nor the money', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['status' => AdCampaign::STATUS_RUNNING]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'уже запущена'); + + // Статус не откатился на «на модерации», отметка о запуске не перебита. + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING) + ->and($campaign->launched_at)->toBeNull(); + + Http::assertNothingSent(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('0.00'); + + $holdExists = AdWalletHold::where('tenant_id', $tenant->id) + ->where('source_type', 'campaign')->where('source_id', $campaign->id) + ->where('status', AdWalletHold::STATUS_ACTIVE)->exists(); + expect($holdExists)->toBeFalse(); +}); + +it('launches a campaign that is queued for the operator', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['status' => AdCampaign::STATUS_QUEUED]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) + ->and($campaign->yandex_campaign_id)->toBe(222) + ->and($campaign->launched_at)->not->toBeNull(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('12000.00'); +}); + +it('does not freeze the money twice when the launch is repeated', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + // Имитация «портал думает, что запуска не было»: статус вернули в черновик. + DB::table('ad_campaigns')->where('id', $campaign->id)->update(['status' => AdCampaign::STATUS_DRAFT]); + + app(CampaignLauncher::class)->launch($campaign->fresh()); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->frozen_rub)->toBe('12000.00'); // а НЕ 24000.00 + + $holds = AdWalletHold::where('tenant_id', $tenant->id) + ->where('channel', 'yandex')->where('source_type', 'campaign') + ->where('source_id', $campaign->id)->where('status', AdWalletHold::STATUS_ACTIVE)->get(); + expect($holds)->toHaveCount(1) + ->and($holds[0]->amount_rub)->toBe('12000.00'); +}); + +it('does not leak yandex_cost_rub or ad_margin_percent into the serialized campaign after launch', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + $json = json_decode(json_encode($campaign->fresh()), true); + expect($json)->not->toHaveKey('yandex_cost_rub') + ->and($json)->not->toHaveKey('ad_margin_percent'); +}); + +/** + * Дата последнего дня показа должна остаться НА КАМПАНИИ и совпадать с EndDate, + * который ушёл в Директ. По ней суточное списание закрывает кампанию, не добравшую + * смету, и возвращает клиенту замороженный остаток. Пока даты не было, такая + * кампания висела `running` вечно, а деньги были заперты навсегда. + */ +it('records the last show day on the campaign, matching the EndDate sent to Direct', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); // run_days = 14 + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + $expected = now()->addDays(14)->toDateString(); + + expect($campaign->refresh()->shows_until->format('Y-m-d'))->toBe($expected); + + Http::assertSent(function ($request) use ($expected) { + if (! str_contains($request->url(), '/json/v5/campaigns')) { + return false; + } + + return ($request['params']['Campaigns'][0]['EndDate'] ?? null) === $expected; + }); +}); + +/* + * ЗАЩИТА ОТ ПОВТОРНОГО ЗАПУСКА. + * + * Проверка «статус draft или queued» была обычным чтением: два одновременных нажатия + * «запустить» проходили её оба, каждый со своего экземпляра модели видел + * `yandex_campaign_id = 0` — и оба шли по всей цепочке. В кабинете получались два сегмента, + * два условия ретаргетинга, ДВЕ медийные кампании, две группы и два комплекта объявлений. + * Заморозка денег при этом одна: портал списал бы за одну кампанию, а крутились бы две. + * + * Ответ 202 «попробуйте чуть позже» на запуск без креативов прямо приглашает нажать ещё раз, + * так что повтор — не экзотика, а штатный сценарий. + * + * Лечение: захват кампании под замком строки и атомарный перевод в промежуточный статус + * `launching`. Второй заход видит его и получает отказ, не сходив в Яндекс ни разу. + */ + +it('второй запуск, пока идёт первый, отбивается и в Яндекс не ходит', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + // Первый запуск «уже идёт»: кампания захвачена и помечена «запускается». + DB::table('ad_campaigns')->where('id', $campaign->id) + ->update(['status' => AdCampaign::STATUS_LAUNCHING, 'updated_at' => now()]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign->fresh())) + ->toThrow(RuntimeException::class, 'Кампания уже запущена'); + + Http::assertNothingSent(); + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_LAUNCHING); +}); + +/** + * Обратная сторона захвата: если процесс запуска убили насмерть (перезагрузка сервера, + * убитый воркер), статус `launching` остался бы навсегда и кампанию нельзя было бы запустить + * уже никогда. Поэтому захват старше пятнадцати минут считается брошенным и перехватывается. + */ +it('зависший захват старше пятнадцати минут перехватывается и запуск доводится до конца', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + DB::table('ad_campaigns')->where('id', $campaign->id)->update([ + 'status' => AdCampaign::STATUS_LAUNCHING, + 'updated_at' => now()->subMinutes(20), + ]); + + app(CampaignLauncher::class)->launch($campaign->fresh()); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); +}); + +/** + * Захват не должен превращаться в ловушку на обычной ошибке: не хватило аудитории — кампания + * обязана вернуться в черновик, чтобы клиент мог исправить и нажать «запустить» снова. + */ +it('неудачный запуск возвращает кампанию в черновик, а не оставляет её захваченной', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 50); // Яндекс не заведёт сегмент меньше ста номеров + seedBanners($campaign, ['300x250' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(AudienceTooSmallException::class); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +/** + * Захват должен быть настоящим: пока запуск идёт, в базе стоит `launching`. Именно эта + * запись и отбивает второе нажатие — без неё второй заход прочитал бы обычный `draft` + * и пошёл заводить вторую кампанию в кабинете Яндекса. + * + * Подсматриваем изнутри запуска: заглушка Яндекса читает статус кампании в базе ровно в тот + * момент, когда запуск уже начался, но ещё не закончился. + */ +it('пока запуск идёт, кампания в базе помечена «запускается»', function () { + configureYandex(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + $statusDuringLaunch = null; + $campaignId = $campaign->id; + + Http::fake(function ($request) use (&$statusDuringLaunch, $campaignId) { + $url = $request->url(); + if (str_contains($url, 'upload_csv_file') && $statusDuringLaunch === null) { + $statusDuringLaunch = DB::table('ad_campaigns')->where('id', $campaignId)->value('status'); + } + + return match (true) { + str_contains($url, 'upload_csv_file'), str_contains($url, '/confirm') => Http::response(['segment' => ['id' => 900001]]), + str_contains($url, '/json/v5/retargetinglists') => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + str_contains($url, '/json/v5/campaigns') => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + str_contains($url, '/json/v5/adgroups') => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + str_contains($url, '/json/v5/audiencetargets') => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + str_contains($url, '/json/v5/creatives') => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), + str_contains($url, '/json/v5/ads') => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), + default => Http::response([], 404), + }; + }); + + app(CampaignLauncher::class)->launch($campaign); + + expect($statusDuringLaunch)->toBe(AdCampaign::STATUS_LAUNCHING) + ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); +}); + +/** + * Номер креатива, равный нулю, — это мусор, а не номер. Приезжает он из ручной правки + * или из чужого импорта. Проверка «номера нет» ловила его случайно, нестрогим сравнением + * (в PHP ноль равен пустоте); стоило написать строгое сравнение «по уму» — и ноль поехал бы + * дальше, в запрос к Яндексу. Ловим намеренно и говорим клиенту то же самое, что и про + * отсутствующий номер: картинки в кабинет ещё не отвезли. + */ +it('баннер с нулевым номером креатива считается без номера, а не отправляется в Яндекс', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 0]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'нет номера креатива'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +/** + * Один и тот же номер креатива, вписанный двум баннерам, дал бы два объявления с одной + * картинкой, и хотя бы одно — не своего размера: клиент платит за показы битого баннера. + * + * Отдельной проверки «номера в наборе не повторяются» не нужно: слот «кампания + размер» + * теперь уникален в базе, значит два баннера набора всегда разных размеров, а сверка + * настоящего размера креатива перед созданием объявлений одного из них обязательно поймает. + * Тест держит это рассуждение: сломается сверка — сломается и он. + */ +it('один номер креатива на двух баннерах не создаёт объявлений', function () { + configureYandex(); + Http::fake([ + '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 4242, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]]), + ]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4242]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'размер'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/json/v5/ads')); + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +/** + * Отметка «отдана клиенту на починку» держит открытым исключение из замка правки. + * Жить ей ровно до нового запуска: объявления созданы — дальше правка снова + * расходилась бы с кабинетом Яндекса. + */ +it('запуск гасит отметку об отданной на починку кампании', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + $campaign->update(['revived_at' => now()]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + 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/app/tests/Feature/Advertising/CampaignMarginHiddenTest.php b/app/tests/Feature/Advertising/CampaignMarginHiddenTest.php new file mode 100644 index 00000000..f15bb592 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignMarginHiddenTest.php @@ -0,0 +1,31 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'X', 'audience_days' => 10, + 'budget_rub' => '500.00', 'yandex_cost_rub' => '380.00', 'charged_client_rub' => '500.00', + ]); + $res = actingAs($user)->getJson("/api/advertising/campaigns/{$c->id}"); + $res->assertOk(); + $json = $res->json('campaign'); + expect($json)->not->toHaveKey('yandex_cost_rub'); + expect($json)->toHaveKey('charged_client_rub'); // деньги клиента — показываем +}); + +it('модель отдаёт yandex_cost_rub при явном доступе (для админки)', function () { + $c = new AdCampaign(['yandex_cost_rub' => '380.00']); + expect((string) $c->yandex_cost_rub)->toBe('380.00'); // атрибут доступен, скрыта только сериализация +}); diff --git a/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php b/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php new file mode 100644 index 00000000..f051829c --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php @@ -0,0 +1,253 @@ +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.0.author'))->toBe(AdCampaignMessage::AUTHOR_YANDEX) + ->and($res->json('messages.1.body'))->toBe('Второе'); +}); + +/** + * Наценка — наша кухня, клиенту её видеть нельзя нигде, включая переписку. + * Ручка отдаёт строго перечисленные поля сообщения, и так должно остаться. + */ +it('в ленте нет ни наценки, ни закупочной цены', function () { + [$tenant, $user, $campaign] = messageEndpointCampaign(); + + AdCampaignMessage::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, 'body' => 'Изображение не подошло', + ]); + + $res = $this->actingAs($user)->getJson("/api/advertising/campaigns/{$campaign->id}/messages"); + + $res->assertOk(); + expect($res->getContent())->not->toContain('yandex_cost_rub') + ->and($res->getContent())->not->toContain('ad_margin_percent') + ->and(array_keys($res->json('messages.0')))->toBe([ + 'id', 'author', 'banner_id', 'body', 'file_name', 'file_size', 'created_at', + ]); +}); + +/** Чужая переписка — чужие бумаги и чужие претензии модератора. */ +it('чужую ленту клиент не видит', function () { + [$tenantA, , $campaignA] = messageEndpointCampaign(); + AdCampaignMessage::create([ + 'tenant_id' => $tenantA->id, 'campaign_id' => $campaignA->id, + 'author' => AdCampaignMessage::AUTHOR_YANDEX, 'body' => 'Чужое', + ]); + + [, $userB] = messageEndpointCampaign(); + + $res = $this->actingAs($userB)->getJson("/api/advertising/campaigns/{$campaignA->id}/messages"); + + $res->assertStatus(404); + expect($res->getContent())->not->toContain('Чужое'); +}); + +it('без входа лента не отдаётся', function () { + [, , $campaign] = messageEndpointCampaign(); + + $this->getJson("/api/advertising/campaigns/{$campaign->id}/messages")->assertStatus(401); +}); + +it('клиент отправляет ответ с документом', function () { + Storage::fake('local'); + [, $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->body)->toBe('Прикладываю свидетельство') + ->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) + ->and(Storage::disk('local')->allFiles())->toBe([]); +}); + +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) + ->and(Storage::disk('local')->allFiles())->toBe([]); +}); + +it('в чужую кампанию написать нельзя', function () { + Storage::fake('local'); + [, , $campaignA] = messageEndpointCampaign(); + [, $userB] = messageEndpointCampaign(); + + $this->actingAs($userB) + ->postJson("/api/advertising/campaigns/{$campaignA->id}/messages", ['body' => 'привет']) + ->assertStatus(404); + + expect(AdCampaignMessage::where('campaign_id', $campaignA->id)->count())->toBe(0); +}); + +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); +}); + +/** + * 🔴 Задача 16 закрыта по факту: везти документ роботом НЕКУДА. Проверено двумя нарочными + * отказами — в окне отказа Яндекса ноль полей для файла, документы он принимает только + * снаружи кабинета. Значит портал обязан сказать клиенту правду: файл у нас, сам он + * никуда не уедет. Молчать нельзя — клиент будет ждать ответа, которого не будет. + */ +it('приложил документ — портал честно говорит, что сам он к Яндексу не уедет', function () { + Storage::fake('local'); + Mail::fake(); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'body' => 'Прикладываю лицензию', + 'file' => UploadedFile::fake()->create('licence.pdf', 200, 'application/pdf'), + ])->assertStatus(201); + + $note = AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_SYSTEM) + ->latest('id')->first(); + + expect($note)->not->toBeNull() + ->and($note->body)->toContain('Яндекс'); +}); + +/** И зовём владельца письмом: без этого «разберёмся вручную» было бы пустым обещанием. */ +it('о приложенном документе владелец узнаёт письмом', function () { + Storage::fake('local'); + Mail::fake(); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'file' => UploadedFile::fake()->create('licence.pdf', 200, 'application/pdf'), + ])->assertStatus(201); + + Mail::assertQueued(AdDocumentAttachedMail::class); +}); + +/** Обычный ответ без файла — обычный ответ. Ни отметки, ни письма владельцу. */ +it('ответ без документа отметки не порождает и владельца не дёргает', function () { + Storage::fake('local'); + Mail::fake(); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'body' => 'Переделал картинку', + ])->assertStatus(201); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_SYSTEM)->count())->toBe(0); + + Mail::assertNothingQueued(); +}); + +/** + * Отметка и письмо — дело второстепенное, а сообщение клиента с документом нет. Беда + * с почтой не должна возвращать клиенту отказ на успешно принятый файл. + */ +it('упавшая почта не мешает принять документ клиента', function () { + Storage::fake('local'); + [, $user, $campaign] = messageEndpointCampaign(); + + Mail::shouldReceive('to')->andThrow(new RuntimeException('почта легла')); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'file' => UploadedFile::fake()->create('licence.pdf', 200, 'application/pdf'), + ])->assertStatus(201); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_CLIENT)->count())->toBe(1); +}); diff --git a/app/tests/Feature/Advertising/CampaignMessageNotifyTest.php b/app/tests/Feature/Advertising/CampaignMessageNotifyTest.php new file mode 100644 index 00000000..c4aad7bd --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignMessageNotifyTest.php @@ -0,0 +1,64 @@ +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, + ]); + + return [$tenant, $user, $campaign]; +} + +it('на слова Яндекса клиенту уходит письмо и загорается колокольчик', function () { + Mail::fake(); + + [$tenant, $user, $campaign] = notifyCampaign(); + + 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, , $campaign] = notifyCampaign(); + + app(CampaignMessageService::class)->postSystem($campaign, 'Документ отправлен в Яндекс'); + + Mail::assertNothingQueued(); + expect(InAppNotification::where('tenant_id', $tenant->id)->count())->toBe(0); +}); + +/** + * Почта — вещь ненадёжная. Если письмо не ушло, сообщение всё равно обязано остаться + * в ленте: клиент увидит его, когда зайдёт. Иначе один сбой почты стирает саму новость. + */ +it('упавшая почта не мешает сообщению лечь в ленту', function () { + [, , $campaign] = notifyCampaign(); + + Mail::shouldReceive('to')->andThrow(new RuntimeException('почта легла')); + + $message = app(CampaignMessageService::class)->postFromYandex($campaign, null, 'Изображение не подошло'); + + expect($message)->not->toBeNull() + ->and($message->exists)->toBeTrue(); +}); diff --git a/app/tests/Feature/Advertising/CampaignMessageServiceTest.php b/app/tests/Feature/Advertising/CampaignMessageServiceTest.php new file mode 100644 index 00000000..fd525037 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignMessageServiceTest.php @@ -0,0 +1,173 @@ +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)); +}); + +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); +}); + +/** + * 🪤 Ловушка, которую открыла разведка, и которую нельзя чинить дедупом по всей истории. + * + * Клиент починил рекламу, отправил заново — и получил ТОТ ЖЕ отказ. Это новость: клиент + * должен узнать, что его опять не пустили, и по той же причине. Дедуп «такой текст уже + * когда-то был» съел бы это сообщение, и клиент остался бы без объяснения. + * + * Поэтому защита от дублей смотрит только на ПОСЛЕДНЕЕ сообщение, а «заглушку» держит + * не она, а джоб модерации — см. SyncCampaignModerationJobTest. + */ +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->postFromRobot($campaign, $banner->id, 'Нет предупреждения: финансовые услуги'); + $service->postFromYandex($campaign, $banner->id, 'Отклонено'); + $again = $service->postFromRobot($campaign, $banner->id, 'Нет предупреждения: финансовые услуги'); + + expect($again)->not->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(3); +}); + +/** Доклад разведки умеет нести снимок экрана — он и есть доказательство прочитанного. */ +it('доклад разведки кладётся со снимком экрана', function () { + [$tenant, $campaign] = messageCampaign(); + + $message = app(CampaignMessageService::class)->postFromRobot($campaign, null, 'Причина', [ + 'path' => 'ad-messages/1/1/okno.png', + 'name' => 'okno.png', + 'size' => 1024, + 'mime' => 'image/png', + ]); + + expect($message->file_name)->toBe('okno.png') + ->and($message->file_path)->toBe('ad-messages/1/1/okno.png') + ->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX); +}); + +/** Пустой доклад — не доклад. В ленту не кладём. */ +it('пустой доклад разведки в ленту не попадает', function () { + [, $campaign] = messageCampaign(); + + expect(app(CampaignMessageService::class)->postFromRobot($campaign, null, ' '))->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** Признак «Яндекс уже что-то сказал» — на нём держится защита от повторной заглушки. */ +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, 'Отклонено'); + + expect($service->hasFromYandex($campaign, $a->id))->toBeTrue() + ->and($service->hasFromYandex($campaign, $b->id))->toBeFalse(); +}); diff --git a/app/tests/Feature/Advertising/CampaignModeModelTest.php b/app/tests/Feature/Advertising/CampaignModeModelTest.php new file mode 100644 index 00000000..bd2e2bfa --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignModeModelTest.php @@ -0,0 +1,85 @@ +create(); + + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Ручной режим', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'snapshot_from' => '2026-07-01', + 'snapshot_to' => '2026-07-20', + 'run_days' => 14, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '2500.00', + 'client_cpm_rub' => '135.50', + ]); + + expect($c->mode)->toBe(AdCampaign::MODE_MANUAL) + ->and($c->snapshot_from)->toBeInstanceOf(Carbon::class) + ->and($c->snapshot_from->format('Y-m-d'))->toBe('2026-07-01') + ->and($c->snapshot_to->format('Y-m-d'))->toBe('2026-07-20') + ->and($c->run_days)->toBe(14) + ->and($c->run_days)->toBeInt() + ->and($c->client_cpm_rub)->toBe('135.50'); +}); + +it('дефолтный режим кампании — auto', function () { + $tenant = Tenant::factory()->create(); + + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Авто по умолчанию', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '2500.00', + ]); + + expect($c->mode)->toBe(AdCampaign::MODE_AUTO); +}); + +it('effectiveCpm() возвращает свою цену кампании, если она задана', function () { + $tenant = Tenant::factory()->create(); + + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Своя цена', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '2500.00', + 'client_cpm_rub' => '99.90', + ]); + + expect($c->effectiveCpm())->toBe('99.90'); +}); + +it('effectiveCpm() возвращает дефолт ad_settings, если своя цена NULL', function () { + DB::table('ad_settings')->update(['client_cpm_rub' => '150.00']); + + $tenant = Tenant::factory()->create(); + + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Без своей цены', + 'audience_days' => 10, + 'use_uploaded_list' => false, + 'weekly_budget_rub' => '2500.00', + ]); + + expect($c->client_cpm_rub)->toBeNull() + ->and($c->effectiveCpm())->toBe('150.00'); +}); diff --git a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php new file mode 100644 index 00000000..9d01ae10 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php @@ -0,0 +1,269 @@ + true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 555, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Нет гарантий'], + ]]], 200), + ]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания на модерации', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => 222, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4555, + 'yandex_ad_id' => 555, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + + app(SyncCampaignModerationJob::class)->handle(); + + $banner->refresh(); + expect($banner->moderation_status)->toBe('REJECTED'); + expect($banner->moderation_reason)->toBe('Нет гарантий'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED); +}); + +it('модерация: все ACCEPTED переводит кампанию в running (passthrough)', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 555, 'Status' => 'ACCEPTED', 'State' => 'ON', 'StatusClarification' => null], + ]]], 200), + ]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания на модерации 2', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => 223, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4555, + 'yandex_ad_id' => 555, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + + app(SyncCampaignModerationJob::class)->handle(); + + $banner->refresh(); + expect($banner->moderation_status)->toBe('ACCEPTED'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +it('AdvertisingStopped ставит кампанию тенанта на паузу через реальный слушатель', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => []], 200), + ]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания в работе', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 224, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + + // Без Event::fake — доказываем, что слушатель реально ЗАРЕГИСТРИРОВАН и сработал. + event(new AdvertisingStopped($tenant->id)); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/campaigns') + && ($request->data()['method'] ?? null) === 'suspend'; + }); +}); + +it('AdvertisingStopped не трогает кампанию чужого тенанта (явный tenant-фильтр)', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => []], 200), + ]); + + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $campaignA = AdCampaign::create([ + 'tenant_id' => $tenantA->id, + 'name' => 'Кампания тенанта A', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 225, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + $campaignB = AdCampaign::create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Кампания тенанта B', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 226, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + + event(new AdvertisingStopped($tenantA->id)); + + $campaignA->refresh(); + $campaignB->refresh(); + + expect($campaignA->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS); + expect($campaignB->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +// ВЫХОД 2 — Яндекс отклонил ВЕСЬ набор объявлений. Показов не было, деньги не +// тратились → заморозка возвращается клиенту полностью. Живое объявление в наборе +// разморозку не запускает — это проверяет SyncCampaignModerationJobTest. +it('ВЫХОД 2: отклонение модерацией снимает заморозку кампании', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 557, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Не та тематика'], + ]]], 200), + ]); + + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания под отказ', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => 227, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4557, + 'yandex_ad_id' => 557, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + app(SyncCampaignModerationJob::class)->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED) + ->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); + +// ВЫХОД 3 — деньги кончились, вся реклама тенанта заглушена. Держать заморозку +// на мёртвой кампании бессмысленно — она только занижает свободный остаток. +it('ВЫХОД 3: остановка без средств снимает заморозку кампании', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => []], 200), + ]); + + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания без денег', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 228, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + event(new AdvertisingStopped($tenant->id)); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS) + ->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); diff --git a/app/tests/Feature/Advertising/CampaignPokazyStoreTest.php b/app/tests/Feature/Advertising/CampaignPokazyStoreTest.php new file mode 100644 index 00000000..42affe06 --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignPokazyStoreTest.php @@ -0,0 +1,117 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $res = actingAs($user)->postJson('/api/advertising/campaigns', [ + 'name' => 'Показы 1', 'audience_days' => 14, + ]); + $res->assertCreated(); + $c = AdCampaign::firstWhere('name', 'Показы 1'); + expect($c)->not->toBeNull(); + expect($c->status)->toBe('draft'); +}); + +it('патчит частоту, показы и бюджет показов', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create(['tenant_id' => $tenant->id, 'name' => 'X', 'audience_days' => 10]); + $res = actingAs($user)->patchJson("/api/advertising/campaigns/{$c->id}", [ + 'frequency' => 5, 'frequency_period_days' => 30, + 'estimated_impressions' => 2500, 'budget_rub' => '300.00', + ]); + $res->assertOk(); + $c->refresh(); + expect($c->frequency)->toBe(5); + expect($c->estimated_impressions)->toBe(2500); + expect((string) $c->budget_rub)->toBe('300.00'); +}); + +it('создаёт кампанию в ручном режиме со своим списком, периодом и сроком показа', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $res = actingAs($user)->postJson('/api/advertising/campaigns', [ + 'name' => 'Ручная', 'audience_days' => 14, 'mode' => 'manual', + 'use_uploaded_list' => true, + 'snapshot_from' => '2026-07-01', 'snapshot_to' => '2026-07-20', 'run_days' => 14, + 'client_cpm_rub' => '99.90', + ]); + $res->assertCreated(); + $c = AdCampaign::firstWhere('name', 'Ручная'); + expect($c->mode)->toBe('manual') + ->and($c->use_uploaded_list)->toBeTrue() + ->and($c->snapshot_from->format('Y-m-d'))->toBe('2026-07-01') + ->and($c->snapshot_to->format('Y-m-d'))->toBe('2026-07-20') + ->and($c->run_days)->toBe(14) + ->and($c->client_cpm_rub)->toBe('99.90'); +}); + +it('в авто-режиме форсит use_uploaded_list=false при создании, даже если пришло true', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $res = actingAs($user)->postJson('/api/advertising/campaigns', [ + 'name' => 'Авто со списком', 'audience_days' => 14, 'mode' => 'auto', + 'use_uploaded_list' => true, + ]); + $res->assertCreated(); + $c = AdCampaign::firstWhere('name', 'Авто со списком'); + expect($c->mode)->toBe('auto') + ->and($c->use_uploaded_list)->toBeFalse(); +}); + +it('переключение на auto через update форсит use_uploaded_list=false', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'Была ручная', 'mode' => 'manual', + 'audience_days' => 10, 'use_uploaded_list' => true, + ]); + + $res = actingAs($user)->patchJson("/api/advertising/campaigns/{$c->id}", ['mode' => 'auto']); + + $res->assertOk(); + $c->refresh(); + expect($c->mode)->toBe('auto') + ->and($c->use_uploaded_list)->toBeFalse(); +}); + +it('update без смены режима у auto-кампании держит use_uploaded_list=false', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'Авто', 'mode' => 'auto', + 'audience_days' => 10, + ]); + + $res = actingAs($user)->patchJson("/api/advertising/campaigns/{$c->id}", ['use_uploaded_list' => true]); + + $res->assertOk(); + $c->refresh(); + expect($c->use_uploaded_list)->toBeFalse(); +}); + +it('update ручной кампании сохраняет use_uploaded_list=true как пришло', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'Ручная', 'mode' => 'manual', + 'audience_days' => 10, + ]); + + $res = actingAs($user)->patchJson("/api/advertising/campaigns/{$c->id}", ['use_uploaded_list' => true]); + + $res->assertOk(); + $c->refresh(); + expect($c->use_uploaded_list)->toBeTrue(); +}); diff --git a/app/tests/Feature/Advertising/CampaignReviveServiceTest.php b/app/tests/Feature/Advertising/CampaignReviveServiceTest.php new file mode 100644 index 00000000..03c99eac --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignReviveServiceTest.php @@ -0,0 +1,121 @@ +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); +}); + +/** + * Принятый баннер трогать нельзя: его объявление живо в кабинете и на нём держится + * возобновляемый запуск. Обнулить ему номера — значит при следующем «Запустить» + * завести в Яндексе второе такое же объявление. + */ +it('принятый баннер оживление не трогает', function () { + config(['services.yandex_direct.enabled' => true]); + Http::fake(['*' => Http::response(['result' => ['DeleteResults' => [['Id' => 200]]]])]); + + [$tenant, $campaign] = rejectedCampaign(); + $accepted = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 728, 'height' => 90, 'bytes' => 1000, 'included' => true, + 'path' => 'ad-banners/a/728x90.jpg', + 'yandex_creative_id' => 300, 'yandex_ad_id' => 400, + 'moderation_status' => AdCampaignBanner::MOD_ACCEPTED, + ]); + + app(CampaignReviveService::class)->revive($campaign); + + expect((int) $accepted->refresh()->yandex_creative_id)->toBe(300) + ->and((int) $accepted->refresh()->yandex_ad_id)->toBe(400); + + // И в Яндекс на удаление ушёл только номер отклонённого объявления. + Http::assertSent(fn ($req) => $req->data()['params']['SelectionCriteria']['Ids'] === [200]); +}); diff --git a/app/tests/Feature/Advertising/CampaignSubmitTest.php b/app/tests/Feature/Advertising/CampaignSubmitTest.php new file mode 100644 index 00000000..addc51aa --- /dev/null +++ b/app/tests/Feature/Advertising/CampaignSubmitTest.php @@ -0,0 +1,95 @@ + $tenantId, 'name' => 'Готовая', 'audience_days' => 10, + 'frequency' => 5, 'estimated_impressions' => 2500, 'budget_rub' => '300.00', + 'banners_approved_at' => now(), + ]); + AdCampaignBanner::create(['tenant_id' => $tenantId, 'campaign_id' => $c->id, + 'width' => 300, 'height' => 250, 'path' => 'x', 'bytes' => 1]); + + return $c; +} + +it('переводит готовую кампанию в статус queued', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = makeReadyCampaign($tenant->id); + $res = actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit"); + $res->assertOk()->assertJson(['status' => 'queued']); + expect($c->fresh()->status)->toBe('queued'); +}); + +it('не отправляет без утверждённых баннеров', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create(['tenant_id' => $tenant->id, 'name' => 'Пустая', + 'audience_days' => 10, 'frequency' => 5, 'estimated_impressions' => 2500, 'budget_rub' => '300.00']); + actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit")->assertStatus(422); + expect($c->fresh()->status)->toBe('draft'); +}); + +it('не отправляет, если все баннеры сняты «в показ» уже после утверждения', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + // Утверждён набор, но единственный баннер потом исключён (included=false). + $c = AdCampaign::create(['tenant_id' => $tenant->id, 'name' => 'Без показа', + 'audience_days' => 10, 'frequency' => 5, 'estimated_impressions' => 2500, 'budget_rub' => '300.00', + 'banners_approved_at' => now()]); + AdCampaignBanner::create(['tenant_id' => $tenant->id, 'campaign_id' => $c->id, + 'width' => 300, 'height' => 250, 'path' => 'x', 'bytes' => 1, 'included' => false]); + + actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit")->assertStatus(422); + expect($c->fresh()->status)->toBe('draft'); +}); + +it('не отправляет ручной режим без периода дат и срока показа', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = makeReadyCampaign($tenant->id); + $c->update(['mode' => AdCampaign::MODE_MANUAL]); + + $res = actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit"); + + $res->assertStatus(422)->assertJson(['message' => 'Для ручного режима задайте период дат и срок показа.']); + expect($c->fresh()->status)->toBe('draft'); +}); + +it('отправляет ручной режим, если период дат и срок показа заданы', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = makeReadyCampaign($tenant->id); + $c->update([ + 'mode' => AdCampaign::MODE_MANUAL, + 'snapshot_from' => '2026-07-01', + 'snapshot_to' => '2026-07-20', + 'run_days' => 14, + ]); + + $res = actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit"); + + $res->assertOk()->assertJson(['status' => 'queued']); + expect($c->fresh()->status)->toBe('queued'); +}); + +it('чужую кампанию не отправляет (404)', function () { + $t1 = Tenant::factory()->create(); + $t2 = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $t1->id]); + $c = makeReadyCampaign($t2->id); + actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit")->assertNotFound(); +}); diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php new file mode 100644 index 00000000..e6f2c231 --- /dev/null +++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php @@ -0,0 +1,162 @@ + true]); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); +} + +/** TSV: накопительное число показов за всё время кампании (ALL_TIME). */ +function fakeYandexImpressionsReport(string $tsv = '2500'): void +{ + Http::fake(['*/json/v5/reports' => Http::response($tsv, 200)]); +} + +function makeRunningCampaign(int $tenantId): AdCampaign +{ + return AdCampaign::create([ + 'tenant_id' => $tenantId, + 'name' => 'Кампания списания', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 222, + 'audience_days' => 10, + 'use_uploaded_list' => true, + 'frequency' => 15, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + ]); +} + +it('charges the client for delivered impressions at the flat CPM (client_cpm_rub not set → default 120.00 ₽/1000)', function () { + configureYandexDirectForSpend(); + fakeYandexImpressionsReport(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); + $campaign = makeRunningCampaign($tenant->id); + + app(ChargeCampaignSpendJob::class)->handle(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + // 2500 показов × 120.00 ₽/1000 = 300.00 ₽ клиенту. + expect($wallet->balance_rub)->toBe('700.00'); // 1000.00 − 300.00 + + $tx = AdWalletTransaction::where('tenant_id', $tenant->id) + ->where('external_key', "yandex-imp:{$campaign->id}:2500") + ->first(); + + expect($tx)->not->toBeNull() + ->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE) + ->and($tx->amount_rub)->toBe('-300.00'); +}); + +/** + * Кампания в суточном списании берётся ПОД ЗАМКОМ строки. + * + * Идемпотентность списания держится на ключе `yandex-imp:{кампания}:{показы}`, а число + * показов приходит из отчёта Директа. Два прогона, начавшихся одновременно (ручной запуск + * поверх расписания, повтор упавшей задачи), получат чуть разные числа показов — значит + * разные ключи, и уникальный индекс по ключу дубль уже не остановит: клиент будет списан + * дважды. Замок строки выстраивает прогоны в очередь: второй увидит уже обновлённый + * `charged_client_rub` и спишет только настоящую дельту. + * + * Настоящую гонку в тесте не поставить, поэтому смотрим на сам запрос: он обязан быть + * блокирующим. Уберите `lockForUpdate()` — тест покраснеет. + */ +it('reads the campaign under a row lock while charging', function () { + configureYandexDirectForSpend(); + fakeYandexImpressionsReport(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); + $campaign = makeRunningCampaign($tenant->id); + + $locked = false; + DB::listen(function ($query) use (&$locked, $campaign) { + if (str_contains($query->sql, 'ad_campaigns') + && str_contains(strtolower($query->sql), 'for update') + && in_array($campaign->id, $query->bindings, true)) { + $locked = true; + } + }); + + app(ChargeCampaignSpendJob::class)->handle(); + + expect($locked)->toBeTrue(); +}); + +it('does not double-charge on a second run (idempotent by external_key)', function () { + configureYandexDirectForSpend(); + fakeYandexImpressionsReport(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); + makeRunningCampaign($tenant->id); + + app(ChargeCampaignSpendJob::class)->handle(); + $balanceAfterFirst = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub; + + app(ChargeCampaignSpendJob::class)->handle(); + $balanceAfterSecond = AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub; + + expect($balanceAfterSecond)->toBe($balanceAfterFirst) + ->and($balanceAfterSecond)->toBe('700.00'); +}); + +it('stops all advertising when the charge leaves the tenant insolvent', function () { + Event::fake([AdvertisingStopped::class]); + configureYandexDirectForSpend(); + fakeYandexImpressionsReport(); + + $tenant = Tenant::factory()->create(); + // Заморозка ТАЕТ вместе со списанием по своей кампании, поэтому нехватку денег + // создаёт ЧУЖОЙ резерв: у клиента запущена вторая кампания с холдом 200.00 ₽, + // а первая съедает 300.00 ₽ из 400.00 ₽ → на вторую денег уже нет (100 < 200) + // → стоп всей рекламы тенанта. + app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест'); + $campaign = makeRunningCampaign($tenant->id); + app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00'); + + app(ChargeCampaignSpendJob::class)->handle(); + + Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id); +}); + +it('does nothing and sends no HTTP when the yandex_direct switch is off', function () { + config(['services.yandex_direct.enabled' => false]); + fakeYandexImpressionsReport(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '1000.00', 'yandex', 'тест'); + makeRunningCampaign($tenant->id); + + app(ChargeCampaignSpendJob::class)->handle(); + + Http::assertNothingSent(); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + expect($wallet->balance_rub)->toBe('1000.00'); +}); diff --git a/app/tests/Feature/Advertising/CreativeJobReaperTest.php b/app/tests/Feature/Advertising/CreativeJobReaperTest.php new file mode 100644 index 00000000..0f0ca442 --- /dev/null +++ b/app/tests/Feature/Advertising/CreativeJobReaperTest.php @@ -0,0 +1,74 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, 'client_cpm_rub' => '120.00', + ]); + + return AdCreativeJob::create(array_merge([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_TAKEN, + 'attempts' => 1, + 'snapshot_before' => [], + 'taken_at' => now()->subMinutes(40), + ], $attributes)); +} + +it('returns a job abandoned in flight back to the queue', function () { + $job = makeStuckJob(); + + $this->artisan('creative-jobs:reap')->assertExitCode(0); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_QUEUED) + ->and($job->fresh()->taken_at)->toBeNull(); +}); + +it('leaves a job that was taken just now alone', function () { + // Робот работает минутами: качает файлы, поднимает браузер, грузит 15 креативов. + // Отобрать у него задание на середине — значит получить два робота на одной кампании. + $job = makeStuckJob(['taken_at' => now()->subMinutes(3)]); + + $this->artisan('creative-jobs:reap')->assertExitCode(0); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_TAKEN); +}); + +it('closes the job as failed once the attempts are spent instead of looping forever', function () { + $job = makeStuckJob(['attempts' => 3]); + + $this->artisan('creative-jobs:reap')->assertExitCode(0); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toContain('не отчитался') + ->and($job->fresh()->finished_at)->not->toBeNull(); +}); + +it('does not touch jobs that are not in flight', function () { + $job = makeStuckJob(['status' => AdCreativeJob::STATUS_DONE, 'taken_at' => now()->subDays(3)]); + + $this->artisan('creative-jobs:reap')->assertExitCode(0); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); diff --git a/app/tests/Feature/Advertising/CreativeJobServiceTest.php b/app/tests/Feature/Advertising/CreativeJobServiceTest.php new file mode 100644 index 00000000..96f044b6 --- /dev/null +++ b/app/tests/Feature/Advertising/CreativeJobServiceTest.php @@ -0,0 +1,823 @@ +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + foreach ($sizes as [$w, $h]) { + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => $h, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/{$w}x{$h}.jpg", + 'bytes' => 1000, + 'included' => true, + ]); + } + + return $campaign; +} + +/** Сообщение клиента с приложенным документом — то, что возит задание вида `deliver`. */ +function docFor(AdCampaign $campaign): AdCampaignMessage +{ + return AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Прикладываю лицензию', + 'file_path' => "ad-docs/{$campaign->tenant_id}/{$campaign->id}/licence.pdf", + 'file_name' => 'licence.pdf', + 'file_size' => 2048, + 'file_mime' => 'application/pdf', + ]); +} + +/** + * Слепок «до» снимается в момент ВЫДАЧИ задания роботу, а не при постановке в очередь. + * + * Пока он снимался при постановке, два задания в очереди получали одинаковый слепок: + * первый робот заливал креативы, а у второго задания «до» оставался старым — и разница + * слепков показывала чужие креативы вдобавок к своим. Второе задание падало ВСЕГДА. + * Плюс постановка ходила в Яндекс прямо из нажатия «запустить»: лёг Яндекс — клиент + * не мог даже поставить кампанию в очередь. + */ +it('enqueues a job without touching Yandex at all', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + + $job = app(CreativeJobService::class)->enqueue($campaign); + + expect($job->status)->toBe(AdCreativeJob::STATUS_QUEUED) + ->and($job->snapshot_before)->toBeNull(); + + Http::assertNothingSent(); +}); + +it('takes the creatives snapshot at hand-out time', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 111, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]])]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + + // Ключевая половина проверки: пока задание ждёт в очереди, слепка у него НЕТ. + // Без неё тест был бы зелёным и при снятии слепка на постановке. + expect($job->snapshot_before)->toBeNull(); + + $taken = app(CreativeJobService::class)->takeNext(); + + expect($taken->status)->toBe(AdCreativeJob::STATUS_TAKEN) + ->and($taken->snapshot_before)->toBe(['111' => [300, 250]]); +}); + +/** + * Яндекс лёг ровно в момент выдачи — задание обязано остаться в очереди, а не сгореть. + * Робот придёт через минуту и заберёт его снова. Всё держится на том, что слепок + * снимается в ТОЙ ЖЕ транзакции, что и перевод задания в работу: не снялся слепок — + * откатился и захват. + */ +it('leaves the job queued when Yandex is down at hand-out time', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['error' => ['error_string' => 'nope']], 503)]); + + $campaign = makeCampaignWithBanners([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + + expect(fn () => app(CreativeJobService::class)->takeNext())->toThrow(RuntimeException::class); + + $fresh = $job->fresh(); + expect($fresh->status)->toBe(AdCreativeJob::STATUS_QUEUED) + ->and($fresh->taken_at)->toBeNull(); +}); + +it('does not enqueue a second job while one is already waiting', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $campaign = makeCampaignWithBanners([[300, 250]]); + + $first = app(CreativeJobService::class)->enqueue($campaign); + $second = app(CreativeJobService::class)->enqueue($campaign); + + expect($second->id)->toBe($first->id) + ->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +it('gives the robot one job at a time and marks it taken', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $a = makeCampaignWithBanners([[300, 250]]); + $b = makeCampaignWithBanners([[728, 90]]); + app(CreativeJobService::class)->enqueue($a); + app(CreativeJobService::class)->enqueue($b); + + $taken = app(CreativeJobService::class)->takeNext(); + expect($taken->status)->toBe(AdCreativeJob::STATUS_TAKEN) + ->and($taken->attempts)->toBe(1); + + // Пока первое задание не завершено, второе не выдаётся — робот работает по одному. + expect(app(CreativeJobService::class)->takeNext())->toBeNull(); +}); + +it('writes creative numbers onto banners when the robot reports done', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) // слепок «до» + ->push(['result' => ['Creatives' => [ // слепок «после» + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ['Id' => 556, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + ]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + app(CreativeJobService::class)->complete($job->fresh()); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners[0]->yandex_creative_id)->toBe(555) + ->and($banners[1]->yandex_creative_id)->toBe(556) + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); + +it('fails the job and touches no banner when the snapshot does not add up', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['result' => ['Creatives' => [ + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + ]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + expect(fn () => app(CreativeJobService::class)->complete($job->fresh())) + ->toThrow(CreativeMatchFailedException::class); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->get(); + expect($banners->pluck('yandex_creative_id')->filter())->toBeEmpty() + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toContain('728x90'); +}); + +/** + * Пустой список ожидаемых размеров — это НЕ «всё сошлось», это тихий ноль. + * + * Раньше `match()` с пустым списком просто не входил в цикл и молча возвращал пустоту. + * А пустой список получается сам собой: если после выката не перезапустить + * `db/03_service_bypass_policies.sql`, служебная роль не увидит ни одного баннера. Дальше + * задание помечается «готово» с нулём проставленных номеров, запуск снова видит баннеры + * без креативов, ставит новое задание — и робот заливает те же файлы по кругу, оставляя + * каждый раз пачку мусорных креативов в живом кабинете. В журнале при этом всё зелёное. + */ +it('refuses to call an empty match a success', function () { + expect(fn () => app(CreativeIdMatcher::class)->match([], [], [])) + ->toThrow(CreativeMatchFailedException::class); +}); + +it('fails a done report for a campaign without banners without asking Yandex at all', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(); + + // Мимо enqueue(): он сам сходил бы в Яндекс за слепком и испортил проверку «не ходили». + $campaign = makeCampaignWithBanners([]); + $job = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_TAKEN, + 'snapshot_before' => [], + 'taken_at' => now(), + ]); + + expect(fn () => app(CreativeJobService::class)->complete($job)) + ->toThrow(CreativeMatchFailedException::class); + + Http::assertNothingSent(); + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED); +}); + +/** + * Рубильник Директа обязан держать и служебный канал робота. + * + * Очередь заданий строила клиента Директа безусловно: выдача задания и приём отчёта ходили + * в живой Яндекс мимо рубильника. То есть при выключенном рубильнике портал всё равно + * стучался в боевой кабинет — ровно то, от чего рубильник и защищает. + */ +it('does not touch Yandex from the robot channel when the Direct switch is off', function () { + config(['services.yandex_direct.enabled' => false]); + Http::fake(); + + $campaign = makeCampaignWithBanners([[300, 250]]); + app(CreativeJobService::class)->enqueue($campaign); + + expect(fn () => app(CreativeJobService::class)->takeNext()) + ->toThrow(RuntimeException::class, 'выключен'); + + Http::assertNothingSent(); +}); + +/** + * Робот должен возить только то, чего в кабинете ещё нет. + * + * Раньше в задание попадали ВСЕ включённые баннеры, даже те, чей креатив уже лежит + * в кабинете. Отсюда два круга беды. Первый: каждая неудачная попытка оставляла в кабинете + * ещё пятнадцать креативов навсегда — почистить их можно только руками. Второй, злее: + * после сбоя задание ставится заново, слепок «до» снимается свежий, в нём уже видны + * прошлые креативы — и опознание падает «не появился креатив размера X» на каждом заходе, + * плодя дубли по кругу. + */ +it('sends the robot only the banners that still have no creative', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + // Креатив для 300×250 робот уже привозил в прошлый заход. + AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 300) + ->update(['yandex_creative_id' => 111]); + + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $banners = app(CreativeJobService::class)->bannersToUpload($job->fresh()); + + expect($banners)->toHaveCount(1) + ->and((int) $banners->first()->width)->toBe(728); +}); + +/** + * Обратная сторона того же: при отчёте сопоставлять надо ровно тот набор, который робот + * вёз. Иначе баннер с уже проставленным номером попал бы в список ожидаемых размеров, + * нового креатива такого размера в слепке не появилось бы — и опознание падало бы на + * ровном месте, хотя робот отработал безупречно. + */ +it('matches only the banners the robot was actually asked to upload', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) // слепок «до» + ->push(['result' => ['Creatives' => [ // слепок «после» + ['Id' => 777, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + ]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 300) + ->update(['yandex_creative_id' => 111]); + + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + app(CreativeJobService::class)->complete($job->fresh()); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners[0]->yandex_creative_id)->toBe(111) // прежний номер не тронут + ->and($banners[1]->yandex_creative_id)->toBe(777) + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); + +/** + * «Задание в работе всегда не больше одного» — инвариант, на котором держится ВСЁ + * опознание креативов: слепки `creatives.get` до/после снимаются по аккаунту целиком, + * и два задания одновременно перемешают их между кампаниями. Размеры IAB у всех + * клиентов одинаковые (300×250, 728×90), поэтому итог — не громкий отказ, а тихая + * привязка ЧУЖОГО номера креатива к баннеру. + * + * Проверки `exists()` в коде мало: она не блокирует строку, и две одновременные + * транзакции обе видят «в работе никого». Гарантию даёт база — частичный уникальный + * индекс. + */ +it('lets the database refuse a second job in flight', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $a = makeCampaignWithBanners([[300, 250]]); + $b = makeCampaignWithBanners([[728, 90]]); + app(CreativeJobService::class)->enqueue($a); + $second = app(CreativeJobService::class)->enqueue($b); + + app(CreativeJobService::class)->takeNext(); // первое ушло в работу + + // Обход сервиса — прямая запись в базу, как сделала бы вторая параллельная транзакция. + expect(fn () => DB::transaction(fn () => AdCreativeJob::where('id', $second->id) + ->update(['status' => AdCreativeJob::STATUS_TAKEN]))) + ->toThrow(QueryException::class); + + expect(AdCreativeJob::where('status', AdCreativeJob::STATUS_TAKEN)->count())->toBe(1); +}); + +/** + * Вид задания. До сих пор робот умел ровно одно — отвезти картинки, и очередь молчаливо + * означала именно это. Теперь он будет ещё ходить смотреть, что кабинет говорит про + * объявление, и возить документ клиента. Вид — явная пометка на задании, а не догадка + * по содержимому. + * + * Умолчание `upload` обязательно: старые задания в очереди на момент выката вида не имеют, + * и без умолчания робот получил бы пустое поле и не понял, что делать. Тот же приём, + * что у `status` в исходной таблице. + */ +it('старому заданию без указания вида достаётся вид «отвезти картинки»', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + ]); + + expect($job->refresh()->kind)->toBe(AdCreativeJob::KIND_UPLOAD); +}); + +/** + * Задание «отвезти документ» обязано помнить, КАКОЙ документ везёт: ссылка на сообщение + * ленты. Без неё робот у кабинета знает только кампанию — а документов у неё может быть + * несколько, и он повезёт не тот. + */ +it('задание может нести вид и ссылку на сообщение с документом', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $message = AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Прикладываю лицензию', + 'file_path' => 'ad-docs/1/1/licence.pdf', + 'file_name' => 'licence.pdf', + 'file_size' => 2048, + 'file_mime' => 'application/pdf', + ]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'kind' => AdCreativeJob::KIND_DELIVER, + 'message_id' => $message->id, + ]); + + $job->refresh(); + + expect($job->kind)->toBe(AdCreativeJob::KIND_DELIVER) + ->and($job->message_id)->toBe($message->id) + ->and($job->message->file_name)->toBe('licence.pdf'); +}); + +/** + * Сообщение удалили, а задание осталось — робот не должен падать на битой ссылке. + * `nullOnDelete` гасит ссылку, задание остаётся в очереди и честно провалится по + * «документа нет», а не уронит всю очередь ошибкой целостности. + */ +it('удаление сообщения гасит ссылку у задания, а не сносит задание', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $message = AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Документ', + ]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'kind' => AdCreativeJob::KIND_DELIVER, + 'message_id' => $message->id, + ]); + + $message->delete(); + + expect(AdCreativeJob::find($job->id))->not->toBeNull() + ->and(AdCreativeJob::find($job->id)->message_id)->toBeNull(); +}); + +/** + * 🔴 Документ, который везёт робот, обязан принадлежать той же кампании. + * + * Обычный внешний ключ этого НЕ давал: проверки целостности в PostgreSQL идут в обход RLS + * и подтверждают только, что строка существует. В портале дыра не выстрелила бы — там RLS, + * чужого сообщения клиент не увидит. Но робот ходит под `crm_admin_user`, которому + * `db/03_service_bypass_policies.sql` даёт кросс-тенантный доступ: он молча увёз бы документ + * ОДНОГО клиента в модерацию кампании ДРУГОГО. Утечка персональных данных без единой ошибки + * в журнале. Найдено проверкой прав доступа, тесты этого увидеть не могли. + * + * Закрыто составным внешним ключом `(message_id, campaign_id)` — теперь отказывает сама база, + * а не наша дисциплина в коде. + */ +it('база не даёт заданию увезти документ чужого клиента', function () { + $mine = makeCampaignWithBanners([[300, 250]]); + $foreign = makeCampaignWithBanners([[300, 250]]); // свой тенант, чужой клиент + + $foreignDoc = AdCampaignMessage::create([ + 'tenant_id' => $foreign->tenant_id, + 'campaign_id' => $foreign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Чужая лицензия', + ]); + + expect(fn () => AdCreativeJob::create([ + 'tenant_id' => $mine->tenant_id, + 'campaign_id' => $mine->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'kind' => AdCreativeJob::KIND_DELIVER, + 'message_id' => $foreignDoc->id, + ]))->toThrow(QueryException::class); +}); + +/** + * Тот же клиент, но другая его кампания — робот тоже повезёт не тот документ, и внешний + * ключ по одному тенанту этого бы не поймал. Поэтому ключ составлен по кампании: она + * принадлежит тенанту, значит проверка по кампании закрывает оба случая разом. + */ +it('база не даёт заданию увезти документ другой кампании того же клиента', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $other = AdCampaign::create([ + 'tenant_id' => $campaign->tenant_id, + 'name' => 'Вторая кампания того же клиента', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $otherDoc = AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $other->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Документ второй кампании', + ]); + + expect(fn () => AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'kind' => AdCreativeJob::KIND_DELIVER, + 'message_id' => $otherDoc->id, + ]))->toThrow(QueryException::class); +}); + +/** + * Своя кампания — задание заводится. Проверка, что защита не заперла нужный случай: + * запрет без этого теста мог бы оказаться запретом на всё. + */ +it('задание со своим документом заводится нормально', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $doc = AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Своя лицензия', + ]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'kind' => AdCreativeJob::KIND_DELIVER, + 'message_id' => $doc->id, + ]); + + expect($job->refresh()->message_id)->toBe($doc->id); +}); + +/** + * 🔴 Остаток той же дыры: ключ по кампании не проверял, что КЛИЕНТ задания совпадает + * с клиентом кампании. + * + * Пока `campaign_id` ссылался на кампанию без клиента, база спокойно принимала строку, + * где задание числится за клиентом A, а кампания принадлежит клиенту B. Живого пути + * к такой строке сегодня нет — постановка берёт оба поля из одной кампании, — но + * задача 16 заводит задания на служебном канале, где RLS не отфильтрует, и допущение + * держалось бы только на аккуратности кода. Найдено повторной проверкой прав доступа: + * первая правка закрыла главное, а это осталось и было воспроизведено вживую. + */ +it('база не даёт завести задание за одного клиента на кампанию другого', function () { + $mine = makeCampaignWithBanners([[300, 250]]); + $foreign = makeCampaignWithBanners([[300, 250]]); + + expect(fn () => AdCreativeJob::create([ + 'tenant_id' => $mine->tenant_id, + 'campaign_id' => $foreign->id, // кампания чужого клиента + 'status' => AdCreativeJob::STATUS_QUEUED, + ]))->toThrow(QueryException::class); +}); + +/** + * То же для ленты сообщений: сообщение не может числиться за одним клиентом, а лежать + * в кампании другого. Иначе документ попадал бы в чужую переписку, а ключ «документ — + * своей кампании» проверял бы принадлежность к кампании, у которой другой владелец. + */ +it('база не даёт положить сообщение одного клиента в кампанию другого', function () { + $mine = makeCampaignWithBanners([[300, 250]]); + $foreign = makeCampaignWithBanners([[300, 250]]); + + expect(fn () => AdCampaignMessage::create([ + 'tenant_id' => $mine->tenant_id, + 'campaign_id' => $foreign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Не туда', + ]))->toThrow(QueryException::class); +}); + +/** + * 🔴 Второй рубеж защиты документа — в коде. Ключи базы (v9.13/v9.15) отказывают уже + * на записи, но полагаться на исключение из базы как на рабочий путь нельзя: оно приходит + * поздно, текстом PostgreSQL, и в транзакции с другими правками. Постановка доставки берёт + * сообщение ТОЛЬКО связью от кампании — чужое просто не находится. + * + * Сырой номер из запроса сюда не попадает никогда: это и есть тот путь, которым робот + * увёз бы чужой документ. + */ +it('доставка не находит документ чужого клиента', function () { + $mine = makeCampaignWithBanners([[300, 250]]); + $foreign = makeCampaignWithBanners([[300, 250]]); + + $foreignDoc = AdCampaignMessage::create([ + 'tenant_id' => $foreign->tenant_id, + 'campaign_id' => $foreign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Чужая лицензия', + 'file_path' => 'ad-docs/9/9/chuzhaya.pdf', + 'file_name' => 'chuzhaya.pdf', + 'file_size' => 10, + 'file_mime' => 'application/pdf', + ]); + + expect(fn () => app(CreativeJobService::class)->enqueueDelivery($mine, $foreignDoc->id)) + ->toThrow(ModelNotFoundException::class); + + expect(AdCreativeJob::where('kind', AdCreativeJob::KIND_DELIVER)->count())->toBe(0); +}); + +it('доставка не находит документ другой кампании того же клиента', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $other = AdCampaign::create([ + 'tenant_id' => $campaign->tenant_id, + 'name' => 'Вторая кампания', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $otherDoc = AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $other->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Документ второй кампании', + 'file_path' => 'ad-docs/1/2/vtoraya.pdf', + 'file_name' => 'vtoraya.pdf', + 'file_size' => 10, + 'file_mime' => 'application/pdf', + ]); + + expect(fn () => app(CreativeJobService::class)->enqueueDelivery($campaign, $otherDoc->id)) + ->toThrow(ModelNotFoundException::class); +}); + +/** + * Везти нечего — задание не ставим. Иначе робот доедет до кабинета, не найдёт файла + * и закроет задание сбоем, а клиент увидит в ленте «не смогли отправить» на ровном месте. + */ +it('доставка не ставит задание, когда в сообщении нет файла', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + + $noFile = AdCampaignMessage::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'author' => AdCampaignMessage::AUTHOR_CLIENT, + 'body' => 'Просто ответ без вложения', + ]); + + expect(fn () => app(CreativeJobService::class)->enqueueDelivery($campaign, $noFile->id)) + ->toThrow(RuntimeException::class); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** Свой документ — задание ставится, вид и ссылка проставлены. */ +it('доставка ставит задание со своим документом', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $doc = docFor($campaign); + + $job = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id); + + expect($job->kind)->toBe(AdCreativeJob::KIND_DELIVER) + ->and($job->message_id)->toBe($doc->id) + ->and($job->status)->toBe(AdCreativeJob::STATUS_QUEUED); +}); + +/** Клиент нажал дважды — второго задания на тот же документ не появляется. */ +it('доставка не плодит задания на один и тот же документ', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $doc = docFor($campaign); + + $first = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id); + $second = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id); + + expect($second->id)->toBe($first->id) + ->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +/** + * 🪤 Ловушка, заложенная появлением видов: постановка обычной заливки искала «любое + * незавершённое задание кампании» и с появлением доставки вернула бы ЕЁ вместо заливки. + * Запуск кампании решил бы, что креативы уже в очереди, и робот не повёз бы картинки + * вовсе — молча, при зелёном журнале. + */ +it('постановка заливки не путает задание доставки со своим', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(); + + $campaign = makeCampaignWithBanners([[300, 250]]); + $doc = docFor($campaign); + + $delivery = app(CreativeJobService::class)->enqueueDelivery($campaign, $doc->id); + $upload = app(CreativeJobService::class)->enqueue($campaign); + + expect($upload->id)->not->toBe($delivery->id) + ->and($upload->kind)->toBe(AdCreativeJob::KIND_UPLOAD); +}); + +/** + * Разведка — единственный способ узнать, за что Яндекс отклонил объявление: программный + * интерфейс отдаёт только «Отклонено на модерации.» и ни слова о причине (проверено живьём + * 28.07.2026, см. ModerationReason). + */ +it('разведка ставится по отклонённому объявлению', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $banner = $campaign->banners()->first(); + $banner->update(['yandex_ad_id' => 17787055204]); + + $job = app(CreativeJobService::class)->enqueueInspection($campaign, $banner); + + expect($job)->not->toBeNull() + ->and($job->kind)->toBe(AdCreativeJob::KIND_INSPECT) + ->and($job->status)->toBe(AdCreativeJob::STATUS_QUEUED) + ->and((int) $job->yandex_ad_id)->toBe(17787055204); +}); + +/** + * 🔴 Опрос модерации бежит по расписанию, а отказ никуда не девается — объявление остаётся + * отклонённым до тех пор, пока клиент его не починит. Без защиты каждый обход ставил бы + * роботу новую разведку: робот ходил бы в кабинет по кругу, а клиент получал бы один и тот же + * доклад снова и снова. + * + * Проверяем и на ЗАКРЫТОМ задании: дедуп только по «незавершённым» не спас бы — первая + * разведка закрывается за минуту, а следующий обход поставил бы вторую. + */ +it('повторный обход второй разведки по тому же объявлению не заводит', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $banner = $campaign->banners()->first(); + $banner->update(['yandex_ad_id' => 17787055204]); + + $service = app(CreativeJobService::class); + $first = $service->enqueueInspection($campaign, $banner); + $first->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]); + + $second = $service->enqueueInspection($campaign, $banner); + + expect($second->id)->toBe($first->id) + ->and(AdCreativeJob::where('campaign_id', $campaign->id) + ->where('kind', AdCreativeJob::KIND_INSPECT)->count())->toBe(1); +}); + +/** + * После кнопки «Исправить» отклонённое объявление удаляется из кабинета, а починенное + * заводится заново и получает НОВЫЙ номер. Новый отказ — это новая беда, и смотреть на неё + * надо своими глазами: причина может быть совсем другой. + */ +it('у нового объявления после починки разведка заводится заново', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $banner = $campaign->banners()->first(); + $banner->update(['yandex_ad_id' => 17787055204]); + + $service = app(CreativeJobService::class); + $first = $service->enqueueInspection($campaign, $banner); + $first->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]); + + $banner->update(['yandex_ad_id' => 17787102785]); + $second = $service->enqueueInspection($campaign, $banner); + + expect($second->id)->not->toBe($first->id) + ->and((int) $second->yandex_ad_id)->toBe(17787102785); +}); + +/** Объявления в кабинете нет — смотреть не на что, робота гонять незачем. */ +it('без номера объявления разведка не ставится', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $banner = $campaign->banners()->first(); + + expect(app(CreativeJobService::class)->enqueueInspection($campaign, $banner))->toBeNull() + ->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** + * 🪤 Та же ловушка, что была с доставкой: постановка обычной заливки ищет незавершённое + * задание кампании. Появись разведка в этой выборке — запуск решил бы, что креативы уже + * в очереди, и робот не повёз бы картинки вовсе, молча. + */ +it('постановка заливки не путает разведку со своим заданием', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(); + + $campaign = makeCampaignWithBanners([[300, 250]]); + $banner = $campaign->banners()->first(); + $banner->update(['yandex_ad_id' => 17787055204]); + + $inspection = app(CreativeJobService::class)->enqueueInspection($campaign, $banner); + $upload = app(CreativeJobService::class)->enqueue($campaign); + + expect($upload->id)->not->toBe($inspection->id) + ->and($upload->kind)->toBe(AdCreativeJob::KIND_UPLOAD); +}); + +/** + * 🔴 Сторож на боевую мину, которую тесты поймать НЕ МОГУТ обычным способом. + * + * Разведку ставит джоб опроса модерации: он бежит в очереди под ролью `crm_app_user` + * (НЕ BYPASSRLS) на дефолтном соединении и БЕЗ tenant-контекста. Без своего `SET LOCAL` + * на бою поиск дубля возвращал бы ноль ВСЕГДА (политика при пустом контексте даёт + * tenant_id = NULL), а запись падала бы «new row violates row-level security policy» — + * молча, в Log::warning, при зелёных тестах. + * + * Проверить это «по-честному» в тестах нельзя: они ходят суперпользователем `postgres`, + * которому права и RLS не проверяются. Поэтому сторожим САМ МЕХАНИЗМ — что контекст + * выставляется, и выставляется на нужного клиента. + */ +it('разведка выставляет свой tenant-контекст — иначе на бою она молча не сработает', function () { + $campaign = makeCampaignWithBanners([[300, 250]]); + $banner = $campaign->banners()->first(); + $banner->update(['yandex_ad_id' => 17787055204]); + + $statements = []; + DB::listen(function ($query) use (&$statements) { + $statements[] = $query->sql; + }); + + app(CreativeJobService::class)->enqueueInspection($campaign, $banner); + + $expected = 'SET LOCAL app.current_tenant_id = '.$campaign->tenant_id; + + expect($statements)->toContain($expected); +}); diff --git a/app/tests/Feature/Advertising/CreativeRobotEndpointTest.php b/app/tests/Feature/Advertising/CreativeRobotEndpointTest.php new file mode 100644 index 00000000..9e6a90cf --- /dev/null +++ b/app/tests/Feature/Advertising/CreativeRobotEndpointTest.php @@ -0,0 +1,545 @@ + 'ROBOTSECRET']); + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); +}); + +function makeRobotCampaign(array $sizes = [[300, 250]]): array +{ + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, 'client_cpm_rub' => '120.00', + ]); + + $banners = []; + foreach ($sizes as [$w, $h]) { + $banners[] = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => $w, 'height' => $h, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/{$w}x{$h}.jpg", + 'bytes' => 1000, 'included' => true, + ]); + } + + return [$campaign, $banners]; +} + +it('rejects a request without the robot token', function () { + $this->getJson('/api/creative-robot/next')->assertStatus(401); +}); + +it('rejects a request with a wrong robot token', function () { + $this->withHeader('X-Creative-Robot-Token', 'nope') + ->getJson('/api/creative-robot/next') + ->assertStatus(401); +}); + +it('closes the channel when no token is configured', function () { + config(['services.creative_robot.token' => '']); + + $this->withHeader('X-Creative-Robot-Token', '') + ->getJson('/api/creative-robot/next') + ->assertStatus(401); +}); + +it('returns nothing to do when the queue is empty', function () { + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk() + ->assertJson(['job' => null]); +}); + +it('hands out a job with the list of banner files to upload', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign] = makeRobotCampaign([[300, 250]]); + app(CreativeJobService::class)->enqueue($campaign); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk() + ->assertJsonPath('job.campaign_id', $campaign->id) + ->assertJsonPath('job.banners.0.width', 300) + ->assertJsonPath('job.banners.0.height', 250) + ->assertJsonStructure(['job' => ['id', 'campaign_id', 'banners' => [['banner_id', 'width', 'height', 'file_url']]]]); +}); + +/** + * Адрес файла содержит номер задания. + * + * Раньше адрес был «баннер такой-то», а задание подбиралось как «какое-нибудь в работе». + * Пока в работе строго одно задание, это совпадало; но защита выдачи чужих картинок + * держалась на честном слове, а не на самом запросе. + */ +it('binds the file address to the job the robot was given', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign] = makeRobotCampaign([[300, 250]]); + app(CreativeJobService::class)->enqueue($campaign); + + $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk(); + + $jobId = $res->json('job.id'); + $bannerId = $res->json('job.banners.0.banner_id'); + + expect($res->json('job.banners.0.file_url')) + ->toBe(url("/api/creative-robot/jobs/{$jobId}/banners/{$bannerId}/file")); +}); + +it('serves a banner file of the job in flight', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + Storage::fake('local'); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + Storage::disk('local')->put($banners[0]->path, 'BINARY'); + + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + // 🪤 Проверяем СОДЕРЖИМОЕ, а не только «200». Незнакомый адрес перехватывает страница + // сайта и тоже отвечает 200 — на голом assertOk() тест зеленел бы без маршрута вовсе. + $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->get("/api/creative-robot/jobs/{$job->id}/banners/{$banners[0]->id}/file") + ->assertOk(); + + expect($res->streamedContent())->toBe('BINARY'); +}); + +/** + * Клиенту разрешены jpg, png и gif, а портал отдавал роботу любой файл под именем «.jpg». + * Робот сохраняет его на диск под этим именем и таким же скармливает кабинету Яндекса — + * то есть PNG приезжает туда как «картинка.jpg». Кабинет либо отвергнет файл, либо примет + * с искажением; разбираться придётся человеку по письму «не смог загрузить». + * + * Отдаём настоящее расширение и настоящий тип содержимого — робот на них и опирается. + */ +it('serves the banner file under its real extension and content type', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + Storage::fake('local'); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + $banners[0]->update(['path' => "ad-banners/x/{$campaign->id}/300x250.png"]); + Storage::disk('local')->put($banners[0]->fresh()->path, 'PNGBINARY'); + + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->get("/api/creative-robot/jobs/{$job->id}/banners/{$banners[0]->id}/file") + ->assertOk(); + + expect($res->headers->get('Content-Type'))->toBe('image/png') + ->and($res->headers->get('Content-Disposition'))->toContain('.png'); +}); + +it('refuses a banner that does not belong to the job in flight', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + Storage::fake('local'); + + [$mine, $mineBanners] = makeRobotCampaign([[300, 250]]); + [, $strangerBanners] = makeRobotCampaign([[728, 90]]); + Storage::disk('local')->put($mineBanners[0]->path, 'BINARY'); + Storage::disk('local')->put($strangerBanners[0]->path, 'BINARY'); + + $job = app(CreativeJobService::class)->enqueue($mine); + app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->get("/api/creative-robot/jobs/{$job->id}/banners/{$strangerBanners[0]->id}/file") + ->assertStatus(404); +}); + +/** + * Файл отдаётся только под номером задания, которое реально в работе. + * + * Берём заведомо трудный случай: номер в адресе — от задания, которое НЕ в работе, а сам + * баннер принадлежит той же кампании, чьё задание сейчас в работе. Пока адрес не был привязан + * к заданию, портал молча подставлял «какое-нибудь задание в работе» и отдавал файл. + */ +it('refuses a file request made under a job that is not in flight', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + Storage::fake('local'); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + Storage::disk('local')->put($banners[0]->path, 'BINARY'); + + app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + // Второе задание той же кампании, мимо сервиса: он бы вернул уже существующее. + $stale = AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'snapshot_before' => [], + ]); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->get("/api/creative-robot/jobs/{$stale->id}/banners/{$banners[0]->id}/file") + ->assertStatus(404); +}); + +it('accepts the done report and writes creative numbers', function () { + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['result' => ['Creatives' => [ + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + ]); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertOk(); + + expect($banners[0]->fresh()->yandex_creative_id)->toBe(555) + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); + +it('accepts a failure report from the robot', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'вход слетел']) + ->assertOk(); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toBe('вход слетел'); +}); + +/** + * Своя беда портала не должна вешать очередь. + * + * Приём отчёта «готово» ходит в живой Яндекс за слепком креативов. Любая ошибка API + * (недоступен, лимит, отвалилась сеть) вылетала наружу необработанной: робот получал 500, + * задание навсегда оставалось «в работе», а выдача заданий при живом «в работе» отвечает + * «работы нет» ВСЕМ — ни одна кампания больше не стартовала бы. + */ +it('does not leave the job in flight when the portal itself fails on the done report', function () { + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['error' => ['error_string' => 'Сервис временно недоступен']], 500), + ]); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertOk() + ->assertJsonPath('status', AdCreativeJob::STATUS_FAILED); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toContain('Сервис временно недоступен') + ->and($banners[0]->fresh()->yandex_creative_id)->toBeNull(); +}); + +/** + * Отчёт принимается ТОЛЬКО по заданию, которое сейчас в работе. + * + * Без этой проверки номер задания брался из адреса как есть: `{ok:true}` по чужому + * `queued`-заданию разложил бы номера креативов ЧУЖОЙ кампании по её баннерам — + * картинка одного клиента уехала бы в объявление другого. А `{ok:false}` по уже + * закрытому заданию переписал бы правильный результат на «сбой». + */ +it('refuses a done report for a job that has not been taken yet', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); // остаётся queued + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertStatus(409); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_QUEUED) + ->and($banners[0]->fresh()->yandex_creative_id)->toBeNull(); +}); + +it('refuses a second done report for a job already finished', function () { + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['result' => ['Creatives' => [ + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + ]); + + [$campaign, $banners] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertOk(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertStatus(409); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE) + ->and($banners[0]->fresh()->yandex_creative_id)->toBe(555); +}); + +it('refuses a failure report that would overwrite a finished job', function () { + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['result' => ['Creatives' => [ + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + ]); + + [$campaign] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertOk(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'обрыв']) + ->assertStatus(409); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); + +it('refuses a report for a job already marked failed', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign] = makeRobotCampaign([[300, 250]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + app(CreativeJobService::class)->fail($job->fresh(), 'первый сбой'); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'второй сбой']) + ->assertStatus(409); + + expect($job->fresh()->failure_reason)->toBe('первый сбой'); +}); + +/** + * Токен проверяем ПЕРВЫМ, а уже потом переключаем соединение с базой на служебную роль. + * Порядок был обратный: запрос без токена сначала переключал соединение и только затем + * получал отказ. Запросов к базе при этом не делалось, поэтому поведение сегодня не меняется — + * это порядок «сначала пропуск, потом ключи от служебного входа», ровно как у админского + * канала (см. комментарий в UseAdminConnection). + * + * Проверить это можно только порядком посредников: наблюдаемой разницы в ответе нет. + * Поэтому тест ничего не гарантирует про поведение — он держит порядок от обратной правки. + */ +it('токен служебного канала проверяется раньше переключения соединения', function () { + $route = collect(app('router')->getRoutes()) + ->first(fn ($r) => $r->uri() === 'api/creative-robot/next'); + + expect($route)->not->toBeNull() + ->and($route->middleware())->toBe(['web', 'creative-robot', 'admin-db']); +}); + +/** + * Разведка — это «сходить посмотреть», а не «отвезти картинки». Роботу нужен номер + * объявления в кабинете и больше ничего. + */ +it('разведку выдаём с номером объявления и без списка картинок', function () { + [$campaign, $banners] = makeRobotCampaign(); + $campaign->update(['yandex_campaign_id' => 713110757]); + $banners[0]->update(['yandex_ad_id' => 17787055204]); + + app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); + + $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk(); + + expect($res->json('job.kind'))->toBe(AdCreativeJob::KIND_INSPECT) + ->and((int) $res->json('job.yandex_ad_id'))->toBe(17787055204) + // Список объявлений в кабинете открывается только по номеру кампании В ЯНДЕКСЕ — + // наш внутренний номер кабинету ничего не говорит. + ->and((int) $res->json('job.yandex_campaign_id'))->toBe(713110757) + ->and($res->json('job.banners'))->toBe([]); +}); + +/** + * 🪤 Слепок креативов снимался при выдаче ЛЮБОГО задания — сетевой вызов в живой Яндекс + * внутри открытой транзакции. Разведке он не нужен вовсе: она ничего не заливает, + * опознавать нечего. Лишний поход — лишний повод очереди встать, когда Яндекс икнул. + */ +it('за слепком креативов ради разведки в Яндекс не ходим', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign, $banners] = makeRobotCampaign(); + $banners[0]->update(['yandex_ad_id' => 17787055204]); + app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk(); + + Http::assertNothingSent(); +}); + +/** Рубильник выключен — робот не получает ни одного задания, в кабинет идти не с чем. */ +it('рубильник выключен — разведку роботу не выдаём', function () { + config(['services.yandex_direct.enabled' => false]); + Http::fake(); + + [$campaign, $banners] = makeRobotCampaign(); + $banners[0]->update(['yandex_ad_id' => 17787055204]); + app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertStatus(500); + + Http::assertNothingSent(); + expect(AdCreativeJob::where('campaign_id', $campaign->id)->first()->status) + ->toBe(AdCreativeJob::STATUS_QUEUED); +}); + +/** Заводит разведку, взятую роботом в работу. */ +function takenInspection(int $adId = 17787055204): array +{ + [$campaign, $banners] = makeRobotCampaign(); + $banners[0]->update(['yandex_ad_id' => $adId]); + app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); + $job = app(CreativeJobService::class)->takeNext(); + + return [$campaign, $banners[0], $job]; +} + +/** + * Ради этого разведка и затевалась: причину отказа Яндекс машине не говорит, её видит + * только робот на экране кабинета. Доклад ложится в ленту СЛОВАМИ ЯНДЕКСА — портал ничего + * не переписывает и не толкует. + */ +it('доклад разведки ложится в ленту от имени Яндекса со снимком экрана', function () { + Storage::fake('local'); + [$campaign, $banner, $job] = takenInspection(); + + $res = $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->post("/api/creative-robot/jobs/{$job->id}/inspection", [ + 'report' => "Нет предупреждения: финансовые услуги\n\nОбъявление о финансовых услугах должно содержать наименование юридического лица.", + 'screenshot' => UploadedFile::fake()->image('okno.png'), + ]); + + $res->assertOk(); + + $message = AdCampaignMessage::where('campaign_id', $campaign->id)->latest('id')->first(); + + expect($message)->not->toBeNull() + ->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX) + ->and((int) $message->banner_id)->toBe((int) $banner->id) + ->and($message->body)->toContain('Нет предупреждения: финансовые услуги') + ->and($message->file_name)->toBe('okno.png') + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); + + Storage::disk('local')->assertExists($message->file_path); +}); + +/** Снимок — дело хорошее, но не обязательное: причина важнее картинки. */ +it('доклад разведки принимается и без снимка', function () { + Storage::fake('local'); + [$campaign, , $job] = takenInspection(17787102785); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->post("/api/creative-robot/jobs/{$job->id}/inspection", [ + 'report' => 'Нет предупреждений: медицина', + ])->assertOk(); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->latest('id')->first()->body) + ->toContain('медицина'); +}); + +/** + * 🔴 Робот не понял, что видит — задание сбойное, и в ленту клиенту НИЧЕГО не сочиняем. + * Придуманная причина хуже молчания: по ней клиент пойдёт переделывать не то. + */ +it('робот растерялся — задание сбойное, а в ленту клиенту ничего не попадает', function () { + Storage::fake('local'); + [$campaign, , $job] = takenInspection(17787055205); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", [ + 'ok' => false, + 'reason' => 'разметка не совпала: окна BannerStatusCell.Popup нет', + ])->assertOk(); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toContain('разметка не совпала') + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** Пустой доклад — это «ничего не выяснил», а не причина отказа. В ленту не пускаем. */ +it('пустой доклад разведки не принимается', function () { + Storage::fake('local'); + [$campaign, , $job] = takenInspection(17787055206); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/inspection", ['report' => ' ']) + ->assertStatus(422); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0) + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_TAKEN); +}); + +/** + * Доклад разведки по заданию заливки означает, что стороны разошлись в том, какую работу + * робот делал. Принять такой доклад — значит положить клиенту в ленту неизвестно что. + */ +it('доклад разведки по заданию заливки не принимается', function () { + Storage::fake('local'); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$campaign] = makeRobotCampaign(); + app(CreativeJobService::class)->enqueue($campaign); + $job = app(CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/inspection", ['report' => 'что-то']) + ->assertStatus(409); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** Задание не в работе — отчёт по нему не принимается, как и у заливки. */ +it('доклад разведки по невыданному заданию не принимается', function () { + Storage::fake('local'); + [$campaign, $banners] = makeRobotCampaign(); + $banners[0]->update(['yandex_ad_id' => 17787055207]); + $job = app(CreativeJobService::class)->enqueueInspection($campaign, $banners[0]); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/inspection", ['report' => 'что-то']) + ->assertStatus(409); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); diff --git a/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php b/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php new file mode 100644 index 00000000..65c5b609 --- /dev/null +++ b/app/tests/Feature/Advertising/SyncCampaignAudienceJobTest.php @@ -0,0 +1,141 @@ + true, + 'services.yandex_audience.token' => 'AUDTOKEN', + ]); + Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]); + + $tenant = Tenant::factory()->create(); + // T3: свой список считается только в mode=manual (auto его игнорирует). + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания ночной заливки', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_segment_id' => 900001, + 'weekly_budget_rub' => '500.00', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'phone' => '79990000001', + 'expires_at' => now()->addDays(5), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(SyncCampaignAudienceJob::class)->handle(); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'modify_data') + && str_contains($request->url(), 'replace'); + }); +}); + +it('рубильник выключен — ни одного обращения к Яндексу', function () { + config([ + 'services.yandex_direct.enabled' => false, + 'services.yandex_audience.token' => 'AUDTOKEN', + ]); + Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания при выключенном рубильнике', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_segment_id' => 900002, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'phone' => '79990000002', + 'expires_at' => now()->addDays(5), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(SyncCampaignAudienceJob::class)->handle(); + + Http::assertNothingSent(); +}); + +it('пустая аудитория кампании — пропускает без обращения к Яндексу', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_audience.token' => 'AUDTOKEN', + ]); + Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]); + + $tenant = Tenant::factory()->create(); + AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания без аудитории', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_segment_id' => 900003, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => false, + ]); + + app(SyncCampaignAudienceJob::class)->handle(); + + Http::assertNothingSent(); +}); + +it('кампания без сегмента Яндекса — пропускает', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_audience.token' => 'AUDTOKEN', + ]); + Http::fake(['*/modify_data*' => Http::response(['ok' => true], 200)]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания без сегмента', + 'status' => AdCampaign::STATUS_RUNNING, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + + DB::table('ad_campaign_phones')->insert([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'phone' => '79990000004', + 'expires_at' => now()->addDays(5), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(SyncCampaignAudienceJob::class)->handle(); + + Http::assertNothingSent(); +}); diff --git a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php new file mode 100644 index 00000000..22407699 --- /dev/null +++ b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php @@ -0,0 +1,524 @@ + true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); +} + +/** + * Кампания на модерации с набором баннеров, у каждого свой номер объявления. + * + * @param list $adIds + * @return array{0: AdCampaign, 1: Collection} + */ +function makeModeratedCampaignWithBanners(array $adIds, int $yandexCampaignId = 222): array +{ + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => $yandexCampaignId, + ]); + + $w = 300; + $banners = collect($adIds)->map(function (int $adId) use ($campaign, &$w) { + $w += 10; + + return AdCampaignBanner::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => 250, + 'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4000 + $adId, + 'yandex_ad_id' => $adId, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + }); + + return [$campaign, $banners]; +} + +it('keeps the campaign running when at least one banner ad is accepted', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([5551, 5552]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING); + + $fresh = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('yandex_ad_id')->get(); + expect($fresh[0]->moderation_status)->toBe(AdCampaignBanner::MOD_ACCEPTED) + ->and($fresh[1]->moderation_status)->toBe(AdCampaignBanner::MOD_REJECTED) + ->and($fresh[1]->moderation_reason)->toBe('Текст на баннере'); +}); + +it('rejects the campaign only when every banner ad is rejected', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5551, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'], + ['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([5551, 5552]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED); +}); + +it('waits while at least one banner ad is still under moderation', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'MODERATION'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([5551, 5552]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); +}); + +/** + * Обход модерации идёт по ВСЕМ кампаниям всех клиентов подряд. Значит любая беда на одной + * кампании обязана остаться внутри неё: сорвётся обход — остальные клиенты не узнают, что + * их реклама принята или отклонена, а деньги за отклонённый набор не вернутся. + * + * Здесь беда приходит не из сети (это уже прикрыто), а из САМОГО ответа Яндекса: объявление + * пришло без статуса. Запись пустого статуса упирается в запрет базы, и обход обрывается + * на середине. + */ +it('объявление без статуса не срывает обход остальных кампаний', function () { + configureYandexForModeration(); + Http::fake(function ($request) { + $ids = $request->data()['params']['SelectionCriteria']['Ids'] ?? []; + + return Http::response(['result' => ['Ads' => in_array(6001, $ids, true) + ? [['Id' => 6001, 'State' => 'ON']] // статуса нет вовсе + : [['Id' => 6002, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED']], + ]]); + }); + + [$broken, $brokenBanners] = makeModeratedCampaignWithBanners([6001], 601); + [$healthy] = makeModeratedCampaignWithBanners([6002], 602); + + (new SyncCampaignModerationJob)->handle(); + + // Кампания без статуса осталась ждать — и не утащила за собой соседнюю. + expect($broken->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION) + ->and(AdCampaignBanner::find($brokenBanners[0]->id)->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION) + ->and($healthy->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING); +}); + +/** + * Причина отказа у Яндекса бывает длинной — там перечисляют все претензии модератора списком. + * В нашей колонке 255 знаков. Длинная причина упирается в базу и обрывает тот же обход. + * Причину показываем клиенту, поэтому храним сколько влезает, а не теряем целиком. + */ +it('слишком длинная причина отказа не срывает обход', function () { + configureYandexForModeration(); + $longReason = str_repeat('Текст на баннере не читается. ', 40); // 1200 знаков + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 6011, 'State' => 'OFF', 'StatusClarification' => $longReason, 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([6011], 611); + + (new SyncCampaignModerationJob)->handle(); + + $reason = AdCampaignBanner::find($banners[0]->id)->moderation_reason; + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED) + ->and(mb_strlen((string) $reason))->toBe(255) + ->and($reason)->toStartWith('Текст на баннере не читается.'); +}); + +// ВЫХОД 2 остаётся ровно один: разморозка зовётся только когда отклонены ВСЕ +// объявления набора. Пока живо хоть одно — деньги остаются замороженными. +it('не снимает заморозку, пока принято хотя бы одно объявление набора', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5561, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ['Id' => 5562, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([5561, 5562], 229); + + $svc = app(AdWalletService::class); + $svc->topup((int) $campaign->tenant_id, '3000.00', 'yandex', 'тест'); + $svc->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, '1200.00'); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING) + ->and(AdWallet::where('tenant_id', $campaign->tenant_id)->first()->frozen_rub)->toBe('1200.00'); +}); + +// --- лента сообщений по кампании ------------------------------------------------- + +it('причина отказа попадает в ленту сообщений кампании', function () { + configureYandexForModeration(); + + $long = str_repeat('Претензия модератора. ', 30); // 660 знаков + + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7001, 'State' => 'OFF', 'StatusClarification' => $long, 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([7001], 701); + + (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($banners[0]->id) + // 🔑 в ленте текст ЦЕЛИКОМ, а в ярлыке — обрезанный до 255 + ->and(mb_strlen((string) $message->body))->toBe(mb_strlen(trim($long))) + ->and(mb_strlen((string) AdCampaignBanner::find($banners[0]->id)->moderation_reason))->toBe(255); +}); + +/** + * Лента — вещь второстепенная, вердикт модерации и деньги — нет. Если сервис сообщений + * лёг, обход обязан доработать до конца: записать статус КАЖДОГО баннера, довести + * кампанию до «отклонено» и вернуть клиенту замороженные деньги. Без защиты первое же + * падение ленты обрывает разбор кампании на первом баннере — второй остаётся без + * вердикта, кампания зависает в ожидании, а деньги остаются замороженными молча. + */ +it('беда с лентой не срывает вердикт по остальным баннерам и возврат денег', function () { + configureYandexForModeration(); + + app()->bind(CampaignMessageService::class, fn () => throw new RuntimeException('лента легла')); + + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7002, 'State' => 'OFF', 'StatusClarification' => 'Не подошло', 'Status' => 'REJECTED'], + ['Id' => 7003, 'State' => 'OFF', 'StatusClarification' => 'Тоже не подошло', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([7002, 7003], 702); + + $svc = app(AdWalletService::class); + $svc->topup((int) $campaign->tenant_id, '3000.00', 'yandex', 'тест'); + $svc->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, '1200.00'); + + (new SyncCampaignModerationJob)->handle(); + + expect(AdCampaignBanner::find($banners[0]->id)->moderation_status)->toBe('REJECTED') + ->and(AdCampaignBanner::find($banners[1]->id)->moderation_status)->toBe('REJECTED') + ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED) + ->and(AdWallet::where('tenant_id', $campaign->tenant_id)->first()->frozen_rub)->toBe('0.00'); +}); + +/* + * Живой ответ Яндекса на отклонённое медийное объявление — снят 28.07.2026 боевым ключом: + * + * { "Status": "REJECTED", "StatusClarification": "\nОтклонено на модерации." } + * + * Причину Яндекс машине НЕ говорит: на экране кабинета в этот же момент висит + * «Нет предупреждения: финансовые услуги» и абзац пояснения. Тесты ниже написаны + * на настоящем ответе, а не на выдуманном: именно на выдуманном дефект и прятался. + */ + +it('немой ответ Яндекса не оставляет клиента без объяснения', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7001, 'State' => 'OFF', 'StatusClarification' => "\nОтклонено на модерации.", 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([7001]); + + (new SyncCampaignModerationJob)->handle(); + + $reason = (string) AdCampaignBanner::find($banners[0]->id)->moderation_reason; + + // Подпись под ярлыком «Отклонено» берётся как ПЕРВАЯ строка причины. У настоящего + // ответа Яндекса первый знак — перенос строки, поэтому клиент видел пустоту. + expect($reason)->not->toStartWith("\n") + ->and(explode("\n", $reason)[0])->not->toBe('') + ->and($reason)->toContain('Яндекс отклонил'); +}); + +it('в ленту вместо отписки Яндекса ложится понятное клиенту сообщение', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7002, 'State' => 'OFF', 'StatusClarification' => "\nОтклонено на модерации.", 'Status' => 'REJECTED'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([7002]); + + (new SyncCampaignModerationJob)->handle(); + + $body = (string) AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_YANDEX)->value('body'); + + expect($body)->not->toBe('Отклонено на модерации.') + ->and($body)->toContain('причину'); +}); + +it('содержательную причину Яндекса не подменяем своими словами', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7003, 'State' => 'OFF', 'StatusClarification' => 'Нет предупреждения: финансовые услуги', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([7003]); + + (new SyncCampaignModerationJob)->handle(); + + expect(AdCampaignBanner::find($banners[0]->id)->moderation_reason) + ->toBe('Нет предупреждения: финансовые услуги'); + + $body = (string) AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_YANDEX)->value('body'); + expect($body)->toBe('Нет предупреждения: финансовые услуги'); +}); + +it('лишние переносы у настоящей причины срезаются, текст остаётся целым', function () { + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7004, 'State' => 'OFF', 'StatusClarification' => "\n Текст на баннере не читается.\nДоработайте макет.\n ", 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([7004]); + + (new SyncCampaignModerationJob)->handle(); + + expect(AdCampaignBanner::find($banners[0]->id)->moderation_reason) + ->toBe("Текст на баннере не читается.\nДоработайте макет."); +}); + +it('принятому объявлению не приписываем отказ, даже когда Яндекс молчит', function () { + // 🪤 Ловушка подмены: «Яндекс причины не назвал» относится ТОЛЬКО к отказу. + // У принятого объявления пустое пояснение — норма, и придумывать там нечего. + configureYandexForModeration(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 7005, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([7005]); + + (new SyncCampaignModerationJob)->handle(); + + expect(AdCampaignBanner::find($banners[0]->id)->moderation_reason)->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_YANDEX)->count())->toBe(0); +}); + +/** + * 🔑 Разведка — единственный способ узнать причину. Программный интерфейс Яндекса на отказ + * отдаёт «Отклонено на модерации.» и ни слова больше: проверено живьём боевым ключом + * 28.07.2026. Пока робот не сходил в кабинет, портал знает только ФАКТ отказа. + */ +it('отказ ставит роботу задание разведки по этому объявлению', function () { + configureYandexForModeration(); + + [$campaign] = makeModeratedCampaignWithBanners([9101]); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9101, 'State' => 'OFF', 'Status' => 'REJECTED', + 'StatusClarification' => "\nОтклонено на модерации.", + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + + $job = AdCreativeJob::where('campaign_id', $campaign->id) + ->where('kind', AdCreativeJob::KIND_INSPECT)->first(); + + expect($job)->not->toBeNull() + ->and((int) $job->yandex_ad_id)->toBe(9101) + ->and($job->status)->toBe(AdCreativeJob::STATUS_QUEUED); +}); + +/** + * Обход бежит по расписанию, а отказ никуда не девается — объявление остаётся отклонённым, + * пока клиент его не починит. Без защиты робот ходил бы в кабинет по кругу. + */ +it('второй обход второй разведки не заводит', function () { + configureYandexForModeration(); + + [$campaign] = makeModeratedCampaignWithBanners([9102]); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9102, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => 'Отклонено', + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + (new SyncCampaignModerationJob)->handle(); + + expect(AdCreativeJob::where('campaign_id', $campaign->id) + ->where('kind', AdCreativeJob::KIND_INSPECT)->count())->toBe(1); +}); + +/** Приняли — смотреть не на что. Робот в кабинет не идёт, кампания крутится. */ +it('принятому объявлению разведку не заводим', function () { + configureYandexForModeration(); + + [$campaign] = makeModeratedCampaignWithBanners([9103]); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9103, 'State' => 'ON', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято', + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** + * Постановка разведки — вещь второстепенная, а вердикт модерации и возврат денег нет. + * Беда с очередью заданий не должна стоить клиенту незаписанного отказа и незамороженных + * обратно денег. + */ +it('беда с постановкой разведки не срывает запись вердикта', function () { + configureYandexForModeration(); + + [$campaign] = makeModeratedCampaignWithBanners([9104]); + + $this->app->bind(CreativeJobService::class, fn () => throw new RuntimeException('очередь легла')); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9104, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => 'Отклонено', + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->banners()->first()->moderation_status)->toBe(AdCampaignBanner::MOD_REJECTED) + ->and($campaign->refresh()->status)->toBe(AdCampaign::STATUS_REJECTED); +}); + +/** + * Рубильник Директа выключен — в Яндекс не ходим и роботу ничего не поручаем. + * Проверяем ОБА: без второго условия выключенный рубильник всё равно наплодил бы заданий, + * которые робот потом понёс бы в живой кабинет. + */ +it('рубильник выключен — ни обращения к Яндексу, ни задания роботу', function () { + config(['services.yandex_direct.enabled' => false]); + + [$campaign] = makeModeratedCampaignWithBanners([9105]); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9105, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => 'Отклонено', + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + + Http::assertNothingSent(); + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0) + ->and($campaign->banners()->first()->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION); +}); + +/** + * 🪤 Ловушка, вскрытая разведкой. Порядок в жизни такой: обход кладёт клиенту «Яндекс + * причину не назвал, выясняем» → робот приносит НАСТОЯЩУЮ причину → следующий обход + * снова кладёт заглушку, ведь последним в ленте лежит уже доклад робота. + * + * Клиент, только что прочитавший настоящую причину, получил бы поверх неё «причину + * не назвали» и ещё одно письмо. Заглушку показываем ОДИН раз — пока сказать нечего. + * + * 🔴 Кампания тут с ДВУМЯ объявлениями: одно приняли, одно отклонили. Иначе сценария + * не существует вовсе — при отказе ВСЕХ объявлений кампания уходит в «отклонена», + * и обход её больше не берёт. Первая версия этого теста была пустышкой ровно поэтому: + * вырезание защиты её не роняло. + */ +it('после доклада разведки заглушка «причину выясняем» в ленту не возвращается', function () { + configureYandexForModeration(); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([9106, 9108]); + $otkloneny = $banners->first(); + + $otvet = ['result' => ['Ads' => [ + ['Id' => 9106, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => ' +Отклонено на модерации.'], + ['Id' => 9108, 'State' => 'ON', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято'], + ]]]; + + Http::fake(['*' => Http::response($otvet)]); + + // Первый обход — заглушка. Кампания остаётся работающей: одно объявление принято. + (new SyncCampaignModerationJob)->handle(); + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); + + // Робот сходил и принёс настоящую причину. + app(CampaignMessageService::class)->postFromRobot( + $campaign, (int) $otkloneny->id, 'Нет предупреждения: финансовые услуги', + ); + + // Второй обход — заглушке в ленте больше не место. + (new SyncCampaignModerationJob)->handle(); + + $bodies = AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('banner_id', $otkloneny->id) + ->orderBy('id')->pluck('body')->all(); + + expect($bodies)->toHaveCount(2) + ->and($bodies[1])->toContain('финансовые услуги'); +}); + +/** + * Обратная сторона: пока Яндекс молчит, а робот ещё не сходил, заглушка нужна — + * иначе под ярлыком «Отклонено» у клиента будет пустота. + */ +it('пока разведка не сходила, заглушка клиенту всё-таки показывается', function () { + configureYandexForModeration(); + + [$campaign] = makeModeratedCampaignWithBanners([9107]); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9107, 'State' => 'OFF', 'Status' => 'REJECTED', + 'StatusClarification' => ' +Отклонено на модерации.', + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + + $first = AdCampaignMessage::where('campaign_id', $campaign->id)->first(); + + expect($first)->not->toBeNull() + ->and($first->body)->toContain('причину не назвал'); +}); diff --git a/app/tests/Feature/Billing/AdvertisingCardTopupTest.php b/app/tests/Feature/Billing/AdvertisingCardTopupTest.php new file mode 100644 index 00000000..615e633e --- /dev/null +++ b/app/tests/Feature/Billing/AdvertisingCardTopupTest.php @@ -0,0 +1,129 @@ + $tenant->id, 'type' => 'topup', 'amount_rub' => '500.00', + 'gateway_id' => $gw->id, 'gateway_code' => 'yookassa', 'gateway_payment_id' => $payId, + 'credit_target' => $creditTarget, + 'status' => 'pending', 'created_at' => now(), + ]); +} + +beforeEach(function () { + $this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']); + $legalEntity = LegalEntity::create([ + 'code' => 'test_le_adv_'.uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO', 'inn' => '7700000000', + ]); + $this->gw = PaymentGateway::create([ + 'code' => 'yookassa_adv_'.uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa', + 'legal_entity_id' => $legalEntity->id, 'config' => '', 'is_active' => true, + 'accepts_methods' => ['card'], 'min_amount_rub' => '100.00', + ]); +}); + +it('settle с credit_target=advertising зачисляет рекламный кошелёк, не баланс за лиды', function () { + $tx = seedPendingCardTx($this->tenant, $this->gw, 'pay_adv_ok', 'advertising'); + $this->mock(PaymentGatewayDriver::class, function ($m) { + $m->shouldReceive('verifyPayment')->once() + ->andReturn(new WebhookVerifyResult('pay_adv_ok', 'succeeded', '500.00', 'RUB', 'bank_card')); + }); + + $resp = $this->postJson('/api/webhook/payment', [ + 'event' => 'payment.succeeded', + 'object' => ['id' => 'pay_adv_ok'], + ]); + + $resp->assertOk(); + $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); + + expect((string) $wallet->balance_rub)->toBe('500.00') + ->and($this->tenant->fresh()->balance_rub)->toBe('0.00') // основной баланс НЕ тронут + ->and($tx->fresh()->status)->toBe('success'); +}); + +it('settle с credit_target=leads (регресс) зачисляет баланс за лиды как раньше', function () { + $tx = seedPendingCardTx($this->tenant, $this->gw, 'pay_leads_ok', 'leads'); + $this->mock(PaymentGatewayDriver::class, function ($m) { + $m->shouldReceive('verifyPayment')->once() + ->andReturn(new WebhookVerifyResult('pay_leads_ok', 'succeeded', '500.00', 'RUB', 'bank_card')); + }); + + $resp = $this->postJson('/api/webhook/payment', [ + 'event' => 'payment.succeeded', + 'object' => ['id' => 'pay_leads_ok'], + ]); + + $resp->assertOk(); + $ledgerId = BalanceTransaction::where('tenant_id', $this->tenant->id) + ->where('type', 'topup')->latest('id')->value('id'); + + expect($this->tenant->fresh()->balance_rub)->toBe('500.00') + ->and(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse() + ->and($tx->fresh()->status)->toBe('success') + ->and($tx->fresh()->balance_rub_after)->toBe('500.00') + ->and($tx->fresh()->balance_transaction_id)->toBe($ledgerId); +}); + +it('идемпотентность: повторный settle advertising не пополняет рекламный кошелёк дважды', function () { + seedPendingCardTx($this->tenant, $this->gw, 'pay_adv_dup', 'advertising'); + $this->mock(PaymentGatewayDriver::class, function ($m) { + $m->shouldReceive('verifyPayment')->twice() + ->andReturn(new WebhookVerifyResult('pay_adv_dup', 'succeeded', '500.00', 'RUB', 'bank_card')); + }); + + $payload = ['event' => 'payment.succeeded', 'object' => ['id' => 'pay_adv_dup']]; + $this->postJson('/api/webhook/payment', $payload)->assertOk(); + $this->postJson('/api/webhook/payment', $payload)->assertOk(); + + $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); + expect((string) $wallet->balance_rub)->toBe('500.00'); // не 1000 +}); + +it('POST /api/billing/topup с credit_target=advertising при флаге ВКЛ создаёт pending-транзакцию рекламного кошелька', function () { + DB::table('system_settings')->updateOrInsert( + ['key' => 'billing_yookassa_enabled'], + ['value' => 'true', 'type' => 'bool', 'updated_at' => now()] + ); + $user = User::factory()->create(['tenant_id' => $this->tenant->id]); + + $this->mock(PaymentGatewayDriver::class, function ($m) { + $m->shouldReceive('createPayment')->once() + ->andReturn(new CreatePaymentResult('pay_adv_start', 'https://yoomoney.ru/checkout/pay_adv_start')); + }); + + $resp = $this->actingAs($user)->postJson('/api/billing/topup', [ + 'amount_rub' => 500, + 'credit_target' => 'advertising', + ]); + + $resp->assertCreated()->assertJson(['confirmation_url' => 'https://yoomoney.ru/checkout/pay_adv_start']); + + $tx = SaasTransaction::where('gateway_payment_id', 'pay_adv_start')->firstOrFail(); + expect($tx->credit_target)->toBe('advertising') + ->and($tx->status)->toBe('pending'); +}); diff --git a/app/tests/Feature/ClientTg/ApproveNotifyTest.php b/app/tests/Feature/ClientTg/ApproveNotifyTest.php index c91e184f..ee26c756 100644 --- a/app/tests/Feature/ClientTg/ApproveNotifyTest.php +++ b/app/tests/Feature/ClientTg/ApproveNotifyTest.php @@ -63,12 +63,15 @@ it('уведомление об одобрении доходит даже бе it('неактивный пользователь уведомление об одобрении НЕ получает', function () { $tenant = Tenant::factory()->create(); - User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); + $user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); $campaign = tgApproveCampaign($tenant->id); app(NotificationService::class)->notifyTelegramCampaignApproved($tenant, $campaign); - expect(InAppNotification::count())->toBe(0); + // 🪤 Считаем уведомления ИМЕННО этого пользователя, а не всю таблицу целиком. + // Глобальный `count()` проходил в одиночку и падал в полном прогоне: любая строка, + // пережившая соседний тест, засчитывалась нам как «уведомление всё-таки ушло». + expect(InAppNotification::where('user_id', $user->id)->count())->toBe(0); }); it('изоляция тенанта: чужой активный юзер уведомление НЕ получает', function () { diff --git a/app/tests/Feature/ClientTg/InputLengthFixTest.php b/app/tests/Feature/ClientTg/InputLengthFixTest.php index 56d0abfd..4a428f7b 100644 --- a/app/tests/Feature/ClientTg/InputLengthFixTest.php +++ b/app/tests/Feature/ClientTg/InputLengthFixTest.php @@ -12,8 +12,10 @@ use Illuminate\Support\Facades\Queue; * колонки проходило валидатор и падало на INSERT (22001), а глобальный хендлер * маскировал это под generic 422 «Не удалось сохранить» (как БАГ схемы в логах). * Теперь граница = размеру колонки → чистая ошибка ПОЛЯ (assertJsonValidationErrors), - * а не замаскированная ошибка БД. Колонки: senders.name varchar(64), - * campaigns.ad_link varchar(500), campaigns.ord_category varchar(200). + * а не замаскированная ошибка БД. Колонки: campaigns.ad_link varchar(500), + * campaigns.ord_category varchar(200). Проверки на senders.name убраны вместе с самой + * сущностью «своё имя отправителя» — она отменена в v9.27 как СМС-фантазия + * (таблица client_tg_senders и её endpoint дропнуты, запрос отдаёт 405). * Синтетические номера 7999… — реальных ПДн нет. */ uses(RefreshDatabase::class); @@ -36,18 +38,6 @@ function lenStorePayload(array $overrides = []): array ], $overrides); } -it('имя отправителя длиннее 64 символов — ошибка поля name, не 500', function () { - $this->postJson('/api/telegram/sender', ['name' => str_repeat('и', 65)]) - ->assertStatus(422) - ->assertJsonValidationErrors('name'); -}); - -it('имя отправителя ровно 64 символа — не ложное срабатывание валидатора', function () { - // На 64 валидатор пропускает; дальше сервис (в песочнице деньги не трогает). - $this->postJson('/api/telegram/sender', ['name' => str_repeat('и', 64)]) - ->assertStatus(201); -}); - it('ad_link длиннее 500 символов — ошибка поля ad_link, не 500', function () { Queue::fake(); diff --git a/app/tests/Feature/ClientTg/RejectNotifyTest.php b/app/tests/Feature/ClientTg/RejectNotifyTest.php index 22ddd6d4..0072a9da 100644 --- a/app/tests/Feature/ClientTg/RejectNotifyTest.php +++ b/app/tests/Feature/ClientTg/RejectNotifyTest.php @@ -103,11 +103,13 @@ it('уведомление об отказе доходит даже без вк it('неактивный пользователь уведомление НЕ получает', function () { $tenant = Tenant::factory()->create(); - User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); + $user = User::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); $campaign = tgRejectCampaign($tenant->id, Campaign::STATUS_RUNNING); $campaign->status_reason = 'Причина'; app(NotificationService::class)->notifyTelegramCampaignRejected($tenant, $campaign); - expect(InAppNotification::count())->toBe(0); + // 🪤 Считаем уведомления ИМЕННО этого пользователя, а не всю таблицу целиком — + // см. такую же правку в ApproveNotifyTest. + expect(InAppNotification::where('user_id', $user->id)->count())->toBe(0); }); diff --git a/app/tests/Feature/ClientTg/ResubmitTest.php b/app/tests/Feature/ClientTg/ResubmitTest.php index 5c231d71..b2e121ab 100644 --- a/app/tests/Feature/ClientTg/ResubmitTest.php +++ b/app/tests/Feature/ClientTg/ResubmitTest.php @@ -32,8 +32,15 @@ beforeEach(function () { $this->actingAs($this->user); }); -/** Отклонённая кампания с причиной и id кабинета (self-contained). */ -function rejectedCampaign(int $tenantId, int $userId, array $overrides = []): Campaign +/** + * Отклонённая кампания с причиной и id кабинета (self-contained). + * + * 🪤 Приставка `tg` не для красоты: помощники Pest — ГЛОБАЛЬНЫЕ функции, и рекламный + * модуль объявляет свой `rejectedCampaign()` (CampaignReviveServiceTest). При сведении + * веток полный прогон падал фаталом «Cannot redeclare» — каждая ветка по отдельности + * этого увидеть не могла. + */ +function tgRejectedCampaign(int $tenantId, int $userId, array $overrides = []): Campaign { return Campaign::create(array_merge([ 'tenant_id' => $tenantId, @@ -62,7 +69,7 @@ function resubmitPayload(array $overrides = []): array it('пересдача (песочница): rejected→queued, правки применены, причина очищена, id кабинета СОХРАНЁН, джоб поставлен', function () { Queue::fake(); - $c = rejectedCampaign($this->tenant->id, $this->user->id); + $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertOk() @@ -84,7 +91,7 @@ it('пересдача (песочница): rejected→queued, правки п it('пересдать нельзя, если кампания не заведена в кабинете (нет mts_campaign_id) → 422, джоб не ставится', function () { Queue::fake(); // Отклонённая, но без id кабинета — «Исправить» нечего (край; в норме id всегда есть). - $c = rejectedCampaign($this->tenant->id, $this->user->id, ['mts_campaign_id' => null]); + $c = tgRejectedCampaign($this->tenant->id, $this->user->id, ['mts_campaign_id' => null]); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertStatus(422); @@ -96,7 +103,7 @@ it('пересдать нельзя, если кампания не заведе it('пересдача с файлом модератору: файл сохранён, путь записан в moderator_file_path', function () { Queue::fake(); Storage::fake('local'); - $c = rejectedCampaign($this->tenant->id, $this->user->id); + $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $file = UploadedFile::fake()->create('licenziya.pdf', 120, 'application/pdf'); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([ @@ -110,7 +117,7 @@ it('пересдача с файлом модератору: файл сохра it('пересдать можно только отклонённую: черновик → 422, джоб не ставится', function () { Queue::fake(); - $c = rejectedCampaign($this->tenant->id, $this->user->id, [ + $c = tgRejectedCampaign($this->tenant->id, $this->user->id, [ 'status' => Campaign::STATUS_DRAFT, 'status_reason' => null, 'mts_campaign_id' => null, @@ -138,7 +145,7 @@ it('пересдача (реальный режим, денег не хвата ]); } - $c = rejectedCampaign($this->tenant->id, $this->user->id); + $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload()) ->assertStatus(409); @@ -148,7 +155,7 @@ it('пересдача (реальный режим, денег не хвата }); it('валидация: без текста/ссылки — 422', function () { - $c = rejectedCampaign($this->tenant->id, $this->user->id); + $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", []) ->assertStatus(422) @@ -157,7 +164,7 @@ it('валидация: без текста/ссылки — 422', function () { it('неверный формат файла (.exe) → 422', function () { Storage::fake('local'); - $c = rejectedCampaign($this->tenant->id, $this->user->id); + $c = tgRejectedCampaign($this->tenant->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$c->id}/resubmit", resubmitPayload([ 'moderator_file' => UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'), @@ -166,7 +173,7 @@ it('неверный формат файла (.exe) → 422', function () { it('изоляция тенанта: пересдача чужой кампании → 404', function () { $tenantB = Tenant::factory()->create(); - $foreign = rejectedCampaign($tenantB->id, $this->user->id); + $foreign = tgRejectedCampaign($tenantB->id, $this->user->id); $this->postJson("/api/telegram/campaigns/{$foreign->id}/resubmit", resubmitPayload()) ->assertStatus(404); diff --git a/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php b/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php new file mode 100644 index 00000000..4cbedba8 --- /dev/null +++ b/app/tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php @@ -0,0 +1,122 @@ +create(['balance_rub' => $balanceRub]); + + return Campaign::create([ + 'tenant_id' => $tenant->id, + 'ad_text' => 'Приходите к нам в канал', + 'ad_link' => 'https://t.me/example_channel', + 'audience_kind' => 'list', + 'budget_cap_rub' => '1000.00', + 'status' => Campaign::STATUS_MODERATING, + ]); +} + +it('списывает под боевой ролью с контекстом клиента — как это делает джоб запуска', function () { + $campaign = tgDenezhnayaKampaniya('5000.00'); + $svc = app(TelegramCampaignChargeService::class); + + podTgBoevoyRolyu(fn () => DB::transaction(function () use ($campaign, $svc) { + DB::statement('SET LOCAL app.current_tenant_id = '.$campaign->tenant_id); + $svc->charge($campaign, '1200.00'); + })); + + // Читаем суперюзером — прибор не должен зависеть от той же защиты, что проверяем. + expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe('3800.00'); + expect(BalanceTransaction::where('related_id', $campaign->id) + ->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1); +}); + +it('возвращает под боевой ролью с контекстом клиента — как это делает джоб модерации', function () { + $campaign = tgDenezhnayaKampaniya('5000.00'); + $svc = app(TelegramCampaignChargeService::class); + $svc->charge($campaign, '1200.00'); + + // Модерация МТС отклонила кампанию: джоб возвращает клиенту всю смету. + podTgBoevoyRolyu(fn () => DB::transaction(function () use ($campaign, $svc) { + DB::statement('SET LOCAL app.current_tenant_id = '.$campaign->tenant_id); + $svc->refund($campaign); + })); + + expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe('5000.00'); + expect(BalanceTransaction::where('related_id', $campaign->id) + ->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)->count())->toBe(1); +}); + +it('без контекста клиента возврат под боевой ролью падает ГРОМКО, а не молчит', function () { + $campaign = tgDenezhnayaKampaniya('5000.00'); + $svc = app(TelegramCampaignChargeService::class); + $svc->charge($campaign, '1200.00'); + + $balanceDo = (string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub; + + // Ровно та же работа, но джоб забыл поставить контекст. Сторож требует ошибку: + // молчаливый выход означал бы «деньги не вернулись, и никто не узнал». + // + // 🪤 Держится это на ОДНОЙ букве: база без контекста молча отдаёт ноль строк + // (`id = NULL` в политике `tenants_self_isolation` не совпадает ни с чем), и громким + // это делает только `firstOrFail()` в сервисе. Заменить его на `first()` — и возврат + // снова станет тихим. Поэтому проверяем именно исключение поиска тенанта. + expect(fn () => podTgBoevoyRolyu(fn () => $svc->refund($campaign))) + ->toThrow(ModelNotFoundException::class); + + // И деньги при этом не тронуты — ни возврата, ни половинчатого движения. + expect((string) Tenant::whereKey($campaign->tenant_id)->first()->balance_rub)->toBe($balanceDo); +}); diff --git a/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php b/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php new file mode 100644 index 00000000..bfee2070 --- /dev/null +++ b/app/tests/Feature/Sales/SalesGuardTokenPriorityTest.php @@ -0,0 +1,51 @@ + 'Head '.uniqid(), + 'email' => 'gtp-head-'.uniqid().'@test.local', + 'password' => Hash::make('secret'), + 'role' => 'head', + 'is_active' => true, + ]); +} + +it('авторизует портал по токену, игнорируя открытую web-сессию обычного кабинета', function () { + $head = gtp_makeHead(); + + // В том же браузере параллельно открыт обычный кабинет — активная web-сессия. + $webUser = User::factory()->create(); + $this->actingAs($webUser, 'web'); + + $token = $head->createToken('sales')->plainTextToken; + + $resp = $this + ->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/sales/overview?period=d30'); + + // Портал должен ответить начальнику (200), а не 500 из-за подмены на User. + $resp->assertOk(); +}); diff --git a/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts b/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts index ed60736d..0e661572 100644 --- a/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts +++ b/app/tests/Frontend/AppMoreDrawerAdvertising.spec.ts @@ -33,9 +33,11 @@ const AD_ITEMS = [ { testid: 'ad-nav-telegram', title: 'Реклама Телеграм' }, ]; -// Каналы-заглушки: клик открывает «В разработке». «Реклама Телеграм» активирована -// (ведёт на реальный экран /advertising/telegram) — она в этот список не входит. -const STUB_ITEMS = AD_ITEMS.filter((i) => i.testid !== 'ad-nav-telegram'); +// Каналы-заглушки: клик открывает «В разработке». Яндекс Аудитория и Реклама Телеграм +// уже ведут на реальные экраны — в этот список они не входят. +const STUB_ITEMS = AD_ITEMS.filter( + (i) => i.testid !== 'ad-nav-yandex-audience' && i.testid !== 'ad-nav-telegram', +); describe('AppMoreDrawer — раздел «Рекламные возможности» на телефоне', () => { it('содержит 5 рекламных пунктов с нужными названиями', async () => { @@ -69,4 +71,24 @@ describe('AppMoreDrawer — раздел «Рекламные возможнос await item.trigger('click'); expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false); }); + + // B2-2: Яндекс Аудитория — теперь реальный переход, не заглушка; клик закрывает «Ещё». + it('клик по «Яндекс Аудитория» НЕ открывает заглушку и закрывает «Ещё»', async () => { + const wrapper = await setup(); + await wrapper.find('[data-testid="ad-nav-yandex-audience"]').trigger('click'); + expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false); + expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false]); + }); + + // T19: «Рекламный кошелёк» — реальная ссылка, не заглушка; клик закрывает «Ещё». + it('содержит «Рекламный кошелёк», клик НЕ открывает заглушку и закрывает «Ещё»', async () => { + const wrapper = await setup(); + const link = wrapper.find('[data-testid="ad-nav-wallet"]'); + expect(link.exists()).toBe(true); + expect(link.text()).toContain('Рекламный кошелёк'); + + await link.trigger('click'); + expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false); + expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false]); + }); }); diff --git a/app/tests/Frontend/AppSidebarAdvertising.spec.ts b/app/tests/Frontend/AppSidebarAdvertising.spec.ts index f3921bdb..6218c8ff 100644 --- a/app/tests/Frontend/AppSidebarAdvertising.spec.ts +++ b/app/tests/Frontend/AppSidebarAdvertising.spec.ts @@ -22,8 +22,8 @@ async function setup(initialRoute = '/deals'): Promise<{ wrapper: VueWrapper; ro global: { plugins: [router, vuetify], stubs: { - // «Реклама Телеграм» теперь реальный RouterLink — стаб должен - // пробрасывать остальные атрибуты (data-testid и т.п.), не только class. + // Яндекс Аудитория и Реклама Телеграм — реальные RouterLink, поэтому стаб + // должен пробрасывать остальные атрибуты (data-testid и т.п.), не только class. RouterLink: { props: ['to'], template: '', @@ -44,9 +44,11 @@ const AD_ITEMS = [ { testid: 'ad-nav-telegram', title: 'Реклама Телеграм' }, ]; -// Каналы-заглушки: клик открывает «В разработке». «Реклама Телеграм» активирована -// (ведёт на реальный экран /advertising/telegram) — она в этот список не входит. -const STUB_ITEMS = AD_ITEMS.filter((i) => i.testid !== 'ad-nav-telegram'); +// Каналы-заглушки: клик открывает «В разработке». Яндекс Аудитория и Реклама Телеграм +// уже ведут на реальные экраны — в этот список они не входят. +const STUB_ITEMS = AD_ITEMS.filter( + (i) => i.testid !== 'ad-nav-yandex-audience' && i.testid !== 'ad-nav-telegram', +); describe('AppSidebar — раздел «Рекламные возможности»', () => { beforeEach(() => localStorage.clear()); @@ -98,4 +100,26 @@ describe('AppSidebar — раздел «Рекламные возможност await wrapper.find('[data-testid="ad-stub-ok"]').trigger('click'); expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false); }); + + // B2-2: Яндекс Аудитория — теперь реальный переход, не заглушка. + it('пункт «Яндекс Аудитория» — ссылка на /advertising/yandex, клик НЕ открывает заглушку', async () => { + const { wrapper } = await setup(); + const link = wrapper.find('[data-testid="ad-nav-yandex-audience"]'); + expect(link.attributes('href')).toBe('/advertising/yandex'); + + await link.trigger('click'); + expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false); + }); + + // T19: «Рекламный кошелёк» — отдельная ссылка (НЕ канал-заглушка), без числа баланса в меню. + it('пункт «Рекламный кошелёк» — ссылка на /advertising/wallet, без суммы баланса в подписи', async () => { + const { wrapper } = await setup(); + const link = wrapper.find('[data-testid="ad-nav-wallet"]'); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('/advertising/wallet'); + expect(link.text()).toBe('Рекламный кошелёк'); + + await link.trigger('click'); + expect(wrapper.find('[data-testid="ad-stub-dialog"]').exists()).toBe(false); + }); }); diff --git a/app/tests/Frontend/admin-advertising-view.spec.ts b/app/tests/Frontend/admin-advertising-view.spec.ts new file mode 100644 index 00000000..9c310ea9 --- /dev/null +++ b/app/tests/Frontend/admin-advertising-view.spec.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import AdminAdvertisingView from '../../resources/js/views/admin/AdminAdvertisingView.vue'; +import type { AdminAdvertisingSpendResponse, AdminAwaitingCampaign } from '../../resources/js/api/admin'; + +vi.mock('../../resources/js/api/admin', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + fetchAdvertisingSpend: vi.fn(), + fetchAdSettings: vi.fn(), + updateAdSettings: vi.fn(), + fetchAdAwaitingCampaigns: vi.fn(), + setAdCampaignCreative: vi.fn(), + fetchAdRobotStuck: vi.fn(), + }; +}); + +const adminApi = await import('../../resources/js/api/admin'); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(adminApi.fetchAdSettings).mockResolvedValue({ client_cpm_rub: '150.00', ad_margin_percent: '40.00' }); + vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValue([]); + vi.mocked(adminApi.fetchAdRobotStuck).mockResolvedValue([]); +}); + +function makeAwaitingCampaign(overrides: Partial = {}): AdminAwaitingCampaign { + return { + id: 1, + tenant_id: 1, + tenant_name: 'Окна Москва', + name: 'Кампания на запуск', + status: 'queued', + yandex_creative_id: null, + landing_url: 'https://example.ru/landing', + estimated_impressions: 12000, + ...overrides, + }; +} + +function makeResponse(overrides: Partial = {}): AdminAdvertisingSpendResponse { + return { + data: [ + { + tenant_id: 1, + tenant_name: 'Окна Москва', + client_spend_rub: '50000.00', + yandex_cost_rub: '40000.00', + our_margin_rub: '10000.00', + }, + { + tenant_id: 2, + tenant_name: 'Двери СПб', + client_spend_rub: '20000.00', + yandex_cost_rub: '16000.00', + our_margin_rub: '4000.00', + }, + ], + totals: { + client_spend_rub: '70000.00', + yandex_cost_rub: '56000.00', + our_margin_rub: '14000.00', + }, + ad_margin_percent: '40.00', + ...overrides, + }; +} + +const mountView = () => + mount(AdminAdvertisingView, { + global: { plugins: [createVuetify()] }, + }); + +describe('AdminAdvertisingView ↔ GET /api/admin/advertising/spend', () => { + it('на mount зовёт fetchAdvertisingSpend(current_month) и рендерит строки + итоговую маржу', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + + const wrapper = mountView(); + await flushPromises(); + + expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(1); + expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledWith('current_month'); + + expect(wrapper.findAll('[data-testid="ad-spend-row"]')).toHaveLength(2); + + const marginEl = wrapper.find('[data-testid="ad-spend-total-margin"]'); + expect(marginEl.exists()).toBe(true); + expect(marginEl.text()).toMatch(/14.000/); + }); + + it('смена периода вызывает перезагрузку с новым period', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValue(makeResponse()); + + const wrapper = mountView(); + await flushPromises(); + expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(1); + + const vm = wrapper.vm as unknown as { period: string }; + vm.period = 'last_month'; + await flushPromises(); + + expect(adminApi.fetchAdvertisingSpend).toHaveBeenCalledTimes(2); + expect(adminApi.fetchAdvertisingSpend).toHaveBeenLastCalledWith('last_month'); + }); + + it('пустые данные → сообщение "За период рекламных расходов нет"', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce( + makeResponse({ + data: [], + totals: { client_spend_rub: '0.00', yandex_cost_rub: '0.00', our_margin_rub: '0.00' }, + }), + ); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.findAll('[data-testid="ad-spend-row"]')).toHaveLength(0); + expect(wrapper.text()).toContain('За период рекламных расходов нет'); + }); + + it('reject → alert с ошибкой виден', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockRejectedValueOnce(new Error('500')); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true); + }); +}); + +describe('AdminAdvertisingView ↔ GET/PUT /api/admin/advertising/settings (client_cpm_rub + ad_margin_percent)', () => { + it('на mount зовёт fetchAdSettings и показывает загруженные значения в полях', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdSettings).mockResolvedValueOnce({ + client_cpm_rub: '220.50', + ad_margin_percent: '35.00', + }); + + const wrapper = mountView(); + await flushPromises(); + + expect(adminApi.fetchAdSettings).toHaveBeenCalledTimes(1); + const input = wrapper.find('[data-testid="ad-cpm-input"] input'); + expect((input.element as HTMLInputElement).value).toBe('220.50'); + const marginInput = wrapper.find('[data-testid="ad-margin-input"] input'); + expect((marginInput.element as HTMLInputElement).value).toBe('35.00'); + }); + + it('клик «Сохранить» зовёт updateAdSettings с обоими полями и показывает "Цена сохранена"', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdSettings).mockResolvedValueOnce({ + client_cpm_rub: '150.00', + ad_margin_percent: '40.00', + }); + vi.mocked(adminApi.updateAdSettings).mockResolvedValueOnce({ + client_cpm_rub: '300.00', + ad_margin_percent: '45.00', + }); + + const wrapper = mountView(); + await flushPromises(); + + const input = wrapper.find('[data-testid="ad-cpm-input"] input'); + await input.setValue('300.00'); + const marginInput = wrapper.find('[data-testid="ad-margin-input"] input'); + await marginInput.setValue('45.00'); + + await wrapper.find('[data-testid="ad-cpm-save"]').trigger('click'); + await flushPromises(); + + expect(adminApi.updateAdSettings).toHaveBeenCalledTimes(1); + expect(adminApi.updateAdSettings).toHaveBeenCalledWith({ + client_cpm_rub: '300.00', + ad_margin_percent: '45.00', + }); + + const saved = wrapper.find('[data-testid="ad-cpm-saved"]'); + expect(saved.exists()).toBe(true); + expect(saved.text()).toContain('Цена сохранена'); + }); + + it('updateAdSettings reject (422) → показывает ошибку, не "Цена сохранена"', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdSettings).mockResolvedValueOnce({ + client_cpm_rub: '150.00', + ad_margin_percent: '40.00', + }); + vi.mocked(adminApi.updateAdSettings).mockRejectedValueOnce(new Error('422')); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="ad-cpm-save"]').trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-testid="ad-cpm-error"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="ad-cpm-saved"]').exists()).toBe(false); + }); +}); + +describe('AdminAdvertisingView ↔ кампании, ждущие номер креатива (8e)', () => { + it('на mount зовёт fetchAdAwaitingCampaigns и рендерит строки', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([ + makeAwaitingCampaign({ id: 1, name: 'Кампания 1' }), + makeAwaitingCampaign({ id: 2, name: 'Кампания 2' }), + ]); + + const wrapper = mountView(); + await flushPromises(); + + expect(adminApi.fetchAdAwaitingCampaigns).toHaveBeenCalledTimes(1); + expect(wrapper.findAll('[data-testid="ad-awaiting-row"]')).toHaveLength(2); + expect(wrapper.text()).toContain('Кампания 1'); + expect(wrapper.text()).toContain('Кампания 2'); + }); + + it('пустой список → сообщение "Кампаний, ждущих номер креатива, нет"', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([]); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.findAll('[data-testid="ad-awaiting-row"]')).toHaveLength(0); + expect(wrapper.find('[data-testid="ad-awaiting-empty"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Кампаний, ждущих номер креатива, нет'); + }); + + it('клик «Сохранить» зовёт setAdCampaignCreative с id кампании и введённым номером', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([ + makeAwaitingCampaign({ id: 42, yandex_creative_id: null }), + ]); + vi.mocked(adminApi.setAdCampaignCreative).mockResolvedValueOnce({ id: 42, yandex_creative_id: 111222333 }); + + const wrapper = mountView(); + await flushPromises(); + + const input = wrapper.find('[data-testid="ad-creative-input"] input'); + await input.setValue('111222333'); + + await wrapper.find('[data-testid="ad-creative-save"]').trigger('click'); + await flushPromises(); + + expect(adminApi.setAdCampaignCreative).toHaveBeenCalledTimes(1); + expect(adminApi.setAdCampaignCreative).toHaveBeenCalledWith(42, 111222333); + + const saved = wrapper.find('[data-testid="ad-creative-saved"]'); + expect(saved.exists()).toBe(true); + expect(saved.text()).toContain('Сохранено'); + }); + + it('setAdCampaignCreative reject → показывает ошибку по строке, не "Сохранено"', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValueOnce([makeAwaitingCampaign({ id: 7 })]); + vi.mocked(adminApi.setAdCampaignCreative).mockRejectedValueOnce(new Error('422')); + + const wrapper = mountView(); + await flushPromises(); + + const input = wrapper.find('[data-testid="ad-creative-input"] input'); + await input.setValue('5'); + + await wrapper.find('[data-testid="ad-creative-save"]').trigger('click'); + await flushPromises(); + + expect(wrapper.find('[data-testid="ad-creative-error"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="ad-creative-saved"]').exists()).toBe(false); + }); +}); + +/** + * 🔴 «Ждёт разбора» — места, где ЦЕПОЧКА ВСТАЛА: робот сходил в кабинет и не понял, что + * видит. Это не обычные отказы (их клиент разбирает сам по причине в переписке), а обрыв, + * о котором иначе никто не узнает: клиент будет ждать ответа, которого не будет. + */ +describe('AdminAdvertisingView ↔ GET /api/admin/advertising/robot-stuck', () => { + it('показывает сбойные задания робота с причиной и номером объявления', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdRobotStuck).mockResolvedValueOnce([ + { + id: 9, + campaign_id: 42, + tenant_id: 1, + tenant_name: 'Окна Москва', + campaign_name: 'Кампания на показы', + campaign_status: 'rejected', + kind: 'inspect', + yandex_ad_id: 17787055204, + failure_reason: 'разметка не совпала: окна BannerStatusCell.Popup нет', + attempts: 1, + finished_at: '2026-07-28T18:00:00+00:00', + }, + ]); + + const wrapper = mountView(); + await flushPromises(); + + const rows = wrapper.findAll('[data-testid="ad-stuck-row"]'); + expect(rows).toHaveLength(1); + expect(rows[0].text()).toContain('Окна Москва'); + expect(rows[0].text()).toContain('разметка не совпала'); + expect(rows[0].text()).toContain('17787055204'); + }); + + it('когда разбирать нечего — говорит об этом прямо, а не показывает пустую таблицу', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="ad-stuck-empty"]').exists()).toBe(true); + expect(wrapper.findAll('[data-testid="ad-stuck-row"]')).toHaveLength(0); + }); +}); diff --git a/app/tests/Frontend/advertising-api-pokazy.spec.ts b/app/tests/Frontend/advertising-api-pokazy.spec.ts new file mode 100644 index 00000000..f169ba8c --- /dev/null +++ b/app/tests/Frontend/advertising-api-pokazy.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() }, + ensureCsrfCookie: vi.fn().mockResolvedValue(undefined), +})); + +import { + fetchAudienceSize, + fetchBanners, + uploadBanner, + toggleBannerIncluded, + deleteBanner, + approveBanners, + submitCampaign, +} from '../../resources/js/api/advertising'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +describe('api/advertising показы', () => { + beforeEach(() => vi.clearAllMocks()); + + it('fetchAudienceSize with frequency passes days+frequency and returns estimate', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { size: 1630, min: 100, enough: true, hint: null, frequency: 15, impressions: 24450, cpm_rub: '120.00', cost_rub: '2934.00' }, + }); + const est = await fetchAudienceSize(1, { days: 10, frequency: 15 }); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/1/audience-size', { params: { days: 10, frequency: 15 } }); + expect(est.cost_rub).toBe('2934.00'); + expect(est.impressions).toBe(24450); + }); + + it('fetchAudienceSize without frequency passes only days', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 0, min: 100, enough: false, hint: 'x' } }); + await fetchAudienceSize(1, { days: 10 }); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/1/audience-size', { params: { days: 10 } }); + }); + + it('fetchAudienceSize with mode=manual passes from/to/cpm, omits days', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { size: 500, min: 100, enough: true, hint: null, cpm_rub: '90.00', cost_rub: '45.00' }, + }); + await fetchAudienceSize(1, { mode: 'manual', from: '2026-07-01', to: '2026-07-15', frequency: 3, cpm: '90.00' }); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/1/audience-size', { + params: { frequency: 3, mode: 'manual', from: '2026-07-01', to: '2026-07-15', cpm: '90.00' }, + }); + }); + + it('fetchBanners GETs banners endpoint and returns approved_at+max_bytes+formats+slots', async () => { + const slot = { + width: 300, + height: 250, + uploaded: true, + banner_id: 7, + bytes: 20000, + included: true, + preview_url: '/api/advertising/campaigns/1/banners/7/preview', + }; + vi.mocked(apiClient.get).mockResolvedValue({ + data: { approved_at: null, max_bytes: 153600, formats: ['jpg', 'jpeg', 'png', 'gif'], slots: [slot] }, + }); + const set = await fetchBanners(1); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/1/banners'); + expect(set.slots).toHaveLength(1); + expect(set.max_bytes).toBe(153600); + expect(set.approved_at).toBeNull(); + }); + + it('uploadBanner POSTs multipart width/height/file to the banners endpoint and returns {slot}', async () => { + const slot = { + width: 728, + height: 90, + uploaded: true, + banner_id: 9, + bytes: 15000, + included: true, + preview_url: '/x', + }; + vi.mocked(apiClient.post).mockResolvedValue({ data: { slot } }); + const file = new File(['x'], 'a.jpg', { type: 'image/jpeg' }); + const res = await uploadBanner(1, 728, 90, file); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(vi.mocked(apiClient.post).mock.calls[0][0]).toBe('/api/advertising/campaigns/1/banners'); + const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData; + expect(sentForm).toBeInstanceOf(FormData); + expect(sentForm.get('width')).toBe('728'); + expect(sentForm.get('height')).toBe('90'); + expect(sentForm.get('file')).toBe(file); + expect(res.slot.banner_id).toBe(9); + }); + + it('toggleBannerIncluded PATCHes the banner endpoint with {included}', async () => { + const slot = { width: 300, height: 250, uploaded: true, banner_id: 7, bytes: 20000, included: false, preview_url: '/x' }; + vi.mocked(apiClient.patch).mockResolvedValue({ data: { slot } }); + const res = await toggleBannerIncluded(1, 7, false); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.patch).toHaveBeenCalledWith('/api/advertising/campaigns/1/banners/7', { included: false }); + expect(res.slot.included).toBe(false); + }); + + it('deleteBanner DELETEs the banner endpoint', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: null }); + await deleteBanner(1, 7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.delete).toHaveBeenCalledWith('/api/advertising/campaigns/1/banners/7'); + }); + + it('approveBanners POSTs approve', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { approved_at: '2026-07-26T00:00:00Z' } }); + const res = await approveBanners(1); + expect(vi.mocked(apiClient.post).mock.calls[0][0]).toBe('/api/advertising/campaigns/1/banners/approve'); + expect(res.approved_at).not.toBeNull(); + }); + + it('submitCampaign postит на submit и возвращает status', async () => { + const post = vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'queued' } } as any); + const r = await submitCampaign(1); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(post).toHaveBeenCalledWith('/api/advertising/campaigns/1/submit'); + expect(r.status).toBe('queued'); + }); +}); diff --git a/app/tests/Frontend/advertising-api.spec.ts b/app/tests/Frontend/advertising-api.spec.ts new file mode 100644 index 00000000..7bda636b --- /dev/null +++ b/app/tests/Frontend/advertising-api.spec.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../resources/js/api/client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, + ensureCsrfCookie: vi.fn().mockResolvedValue(undefined), +})); + +import { + fetchWallet, + fetchCampaigns, + fetchCampaign, + createCampaign, + patchCampaign, + fetchAudienceSize, + launchCampaign, + pauseCampaign, + resumeCampaign, + addCreative, + uploadCreativeImage, + deleteCampaign, + uploadCampaignPhones, + createAdvertisingInvoice, + topupAdvertisingByCard, +} from '../../resources/js/api/advertising'; +import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client'; + +describe('api/advertising', () => { + beforeEach(() => vi.clearAllMocks()); + + it('fetchWallet() GETs /api/advertising/wallet', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { solvent: true, balance_rub: '100.00', frozen_rub: '20.00', free_rub: '80.00' }, + }); + const w = await fetchWallet(); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/wallet'); + expect(w.solvent).toBe(true); + expect(w.free_rub).toBe('80.00'); + }); + + it('fetchCampaigns() hits GET /api/advertising/campaigns and unwraps data.data', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + data: [ + { + id: 1, + name: 'C', + status: 'draft', + weekly_budget_rub: '500.00', + audience_days: 10, + launched_at: null, + }, + ], + }, + }); + const rows = await fetchCampaigns(); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns'); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('C'); + }); + + it('fetchCampaigns() returns [] when data.data missing', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: {} }); + const rows = await fetchCampaigns(); + expect(rows).toEqual([]); + }); + + it('fetchCampaign(id) GETs /api/advertising/campaigns/{id} and returns campaign+ads+spent_rub', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + campaign: { + id: 3, + name: 'X', + status: 'running', + weekly_budget_rub: '1000.00', + audience_days: 20, + launched_at: '2026-07-01', + }, + ads: [], + spent_rub: '10.00', + }, + }); + const detail = await fetchCampaign(3); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3'); + expect(detail.campaign.id).toBe(3); + expect(detail.spent_rub).toBe('10.00'); + }); + + it('createCampaign() ensures csrf then POSTs /api/advertising/campaigns with payload', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { + id: 5, + name: 'X', + status: 'draft', + weekly_budget_rub: '2500.00', + audience_days: 10, + launched_at: null, + }, + }); + const payload = { name: 'X', audience_days: 10, use_uploaded_list: false, weekly_budget_rub: '2500.00' }; + const c = await createCampaign(payload); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns', payload); + expect(c.id).toBe(5); + }); + + it('patchCampaign() ensures csrf then PATCHes /api/advertising/campaigns/{id}', async () => { + vi.mocked(apiClient.patch).mockResolvedValue({ + data: { + id: 5, + name: 'X2', + status: 'draft', + weekly_budget_rub: '3000.00', + audience_days: 15, + launched_at: null, + }, + }); + const c = await patchCampaign(5, { weekly_budget_rub: '3000.00' }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.patch).toHaveBeenCalledWith('/api/advertising/campaigns/5', { weekly_budget_rub: '3000.00' }); + expect(c.name).toBe('X2'); + }); + + it('fetchAudienceSize() passes days as query param when given', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 120, min: 100, enough: true, hint: null } }); + const s = await fetchAudienceSize(3, { days: 14 }); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3/audience-size', { + params: { days: 14 }, + }); + expect(s.enough).toBe(true); + }); + + it('fetchAudienceSize() omits days param when not given', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { size: 5, min: 100, enough: false, hint: 'мало' } }); + const s = await fetchAudienceSize(3); + expect(apiClient.get).toHaveBeenCalledWith('/api/advertising/campaigns/3/audience-size', { params: {} }); + expect(s.enough).toBe(false); + }); + + it('launchCampaign() ensures csrf then POSTs launch endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'pending_moderation' } }); + const res = await launchCampaign(7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/launch'); + expect(res.status).toBe('pending_moderation'); + }); + + it('pauseCampaign() ensures csrf then POSTs pause endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'paused' } }); + const res = await pauseCampaign(7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/pause'); + expect(res.status).toBe('paused'); + }); + + it('resumeCampaign() ensures csrf then POSTs resume endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'running' } }); + const res = await resumeCampaign(7); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/resume'); + expect(res.status).toBe('running'); + }); + + it('addCreative() ensures csrf then POSTs ads endpoint with payload', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { + id: 9, + campaign_id: 7, + title: 'T', + text: 'Txt', + href: 'https://x.ru', + title2: null, + moderation_status: 'draft', + }, + }); + const payload = { title: 'T', text: 'Txt', href: 'https://x.ru' }; + const ad = await addCreative(7, payload); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/ads', payload); + expect(ad.id).toBe(9); + }); + + it('uploadCreativeImage() ensures csrf then POSTs multipart FormData to image endpoint', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { hash: 'abc123' } }); + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + const res = await uploadCreativeImage(7, 9, file); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/ads/9/image', expect.any(FormData)); + const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData; + expect(sentForm.get('file')).toBe(file); + expect(res.hash).toBe('abc123'); + }); + + it('deleteCampaign() ensures csrf then DELETEs /api/advertising/campaigns/{id}', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: null }); + await deleteCampaign(12); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.delete).toHaveBeenCalledWith('/api/advertising/campaigns/12'); + }); + + it('uploadCampaignPhones() ensures csrf then POSTs FormData to phones endpoint and returns {recognized,skipped}', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { recognized: 10, skipped: 2 } }); + const file = new File(['79261234567'], 'phones.txt', { type: 'text/plain' }); + const res = await uploadCampaignPhones(7, { file, text: '79261111111' }); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/advertising/campaigns/7/phones', expect.any(FormData)); + const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData; + expect(sentForm.get('file')).toBe(file); + expect(sentForm.get('text')).toBe('79261111111'); + expect(res).toEqual({ recognized: 10, skipped: 2 }); + }); + + it('uploadCampaignPhones() omits file/text from FormData when not given', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { recognized: 3, skipped: 0 } }); + await uploadCampaignPhones(7, {}); + const sentForm = vi.mocked(apiClient.post).mock.calls[0][1] as FormData; + expect(sentForm.get('file')).toBeNull(); + expect(sentForm.get('text')).toBeNull(); + }); + + it('createAdvertisingInvoice() ensures csrf then POSTs /api/billing/invoices с credit_target=advertising', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { + invoice: { + id: 1, + invoice_number: 'СЧ-2026-0001', + amount_total: '5000.00', + pdf_url: '/api/billing/invoices/1/pdf', + }, + }, + }); + const invoice = await createAdvertisingInvoice(5000); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/billing/invoices', { + amount_rub: 5000, + credit_target: 'advertising', + }); + expect(invoice.invoice_number).toBe('СЧ-2026-0001'); + expect(invoice.pdf_url).toBe('/api/billing/invoices/1/pdf'); + }); + + it('topupAdvertisingByCard() ensures csrf then POSTs /api/billing/topup с credit_target=advertising (шлюз ВКЛ → confirmation_url)', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ + data: { confirmation_url: 'https://yoomoney.ru/checkout/pay_x' }, + }); + const res = await topupAdvertisingByCard(5000); + expect(ensureCsrfCookie).toHaveBeenCalledOnce(); + expect(apiClient.post).toHaveBeenCalledWith('/api/billing/topup', { + amount_rub: 5000, + credit_target: 'advertising', + }); + expect(res.confirmation_url).toBe('https://yoomoney.ru/checkout/pay_x'); + }); + + it('topupAdvertisingByCard() returns {ok:true} when the gateway is off (stub instant credit)', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { ok: true } }); + const res = await topupAdvertisingByCard(3000); + expect(res.ok).toBe(true); + expect(res.confirmation_url).toBeUndefined(); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-list-pokazy.spec.ts b/app/tests/Frontend/advertising-campaign-list-pokazy.spec.ts new file mode 100644 index 00000000..c8852a24 --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-list-pokazy.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +const { fetchCampaignsMock } = vi.hoisted(() => ({ + fetchCampaignsMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchCampaigns: fetchCampaignsMock, + pauseCampaign: vi.fn(), + resumeCampaign: vi.fn(), + deleteCampaign: vi.fn(), +})); + +import CampaignList from '../../resources/js/components/advertising/CampaignList.vue'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('CampaignList.vue (Ч.5b — показы, статус queued + показы вместо клик-бюджета)', () => { + beforeEach(() => { + fetchCampaignsMock.mockReset(); + }); + + it('queued — метка «Готова к запуску», карточка показывает показы/бюджет показов, а не «Бюджет в неделю»', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 5, + name: 'X', + status: 'queued', + audience_days: 10, + frequency: 5, + estimated_impressions: 8150, + budget_rub: '978.00', + launched_at: null, + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const chip = w.find('[data-testid="campaign-status-chip"]'); + expect(chip.text()).toContain('Готова к запуску'); + + const card = w.find('[data-testid="campaign-card"]'); + expect(card.text()).toContain('8150'); + expect(card.text()).toContain('978'); + expect(card.text()).not.toContain('Бюджет в неделю'); + expect(card.text()).not.toContain('Цена за клик'); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-list.spec.ts b/app/tests/Frontend/advertising-campaign-list.spec.ts new file mode 100644 index 00000000..448be8bb --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-list.spec.ts @@ -0,0 +1,492 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +const { + fetchCampaignsMock, + pauseCampaignMock, + resumeCampaignMock, + fetchCampaignMock, + deleteCampaignMock, + reviveCampaignMock, +} = vi.hoisted(() => ({ + fetchCampaignsMock: vi.fn(), + pauseCampaignMock: vi.fn(), + resumeCampaignMock: vi.fn(), + fetchCampaignMock: vi.fn(), + deleteCampaignMock: vi.fn(), + reviveCampaignMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchCampaigns: fetchCampaignsMock, + pauseCampaign: pauseCampaignMock, + resumeCampaign: resumeCampaignMock, + fetchCampaign: fetchCampaignMock, + deleteCampaign: deleteCampaignMock, + reviveCampaign: reviveCampaignMock, +})); + +import CampaignList from '../../resources/js/components/advertising/CampaignList.vue'; + +const vuetify = createVuetify(); + +/** + * VDialog в jsdom (без реальных transitions/teleport target) не рендерит слот + * надёжно через `mount` — тот же приём, что и в NewDealDialog.spec.ts: стабим + * компонент собственным условным рендером по `modelValue`. + */ +const DIALOG_STUBS = { + VDialog: { + template: '
', + props: ['modelValue'], + }, +}; + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)', () => { + beforeEach(() => { + fetchCampaignsMock.mockReset(); + pauseCampaignMock.mockReset(); + resumeCampaignMock.mockReset(); + fetchCampaignMock.mockReset(); + deleteCampaignMock.mockReset(); + fetchCampaignMock.mockResolvedValue({ + campaign: { + id: 1, + name: 'X', + status: 'running', + weekly_budget_rub: '100.00', + audience_days: 7, + launched_at: null, + }, + ads: [], + spent_rub: '0.00', + }); + }); + + it('рендерит карточки кампаний со статус-чипами', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 1, + name: 'Директора СПб', + status: 'running', + weekly_budget_rub: '5000.00', + audience_days: 30, + launched_at: '2026-07-20T10:00:00Z', + }, + { + id: 2, + name: 'Директора Москвы', + status: 'rejected', + weekly_budget_rub: '3000.00', + audience_days: 14, + launched_at: null, + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const cards = w.findAll('[data-testid="campaign-card"]'); + expect(cards).toHaveLength(2); + expect(cards[0].text()).toContain('Директора СПб'); + expect(cards[1].text()).toContain('Директора Москвы'); + + const chips = w.findAll('[data-testid="campaign-status-chip"]'); + expect(chips[0].text()).toContain('Крутится'); + expect(chips[1].text()).toContain('Отклонено'); + }); + + it('пустой список показывает пустое состояние с кнопкой «Новая реклама», которая эмитит new', async () => { + fetchCampaignsMock.mockResolvedValue([]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const empty = w.find('[data-testid="campaign-empty"]'); + expect(empty.exists()).toBe(true); + + await w.find('[data-testid="campaign-new-btn"]').trigger('click'); + expect(w.emitted('new')).toBeTruthy(); + }); + + it('клик «Изменить» на карточке эмитит edit с id кампании', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 42, + name: 'Тестовая кампания', + status: 'draft', + weekly_budget_rub: '1000.00', + audience_days: 7, + launched_at: null, + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-edit-btn"]').trigger('click'); + expect(w.emitted('edit')).toBeTruthy(); + expect(w.emitted('edit')?.[0]).toEqual([42]); + }); + + it('running-кампания показывает кнопку «Пауза»; клик ставит на паузу и перезагружает список', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 10, + name: 'Растущая', + status: 'running', + weekly_budget_rub: '2000.00', + audience_days: 10, + launched_at: '2026-07-20T10:00:00Z', + }, + ]); + pauseCampaignMock.mockResolvedValue({ status: 'paused' }); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const pauseBtn = w.find('[data-testid="campaign-pause-btn"]'); + expect(pauseBtn.exists()).toBe(true); + expect(w.find('[data-testid="campaign-resume-btn"]').exists()).toBe(false); + + await pauseBtn.trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(pauseCampaignMock).toHaveBeenCalledWith(10); + // Перезагрузка списка после паузы — loadCampaigns вызывается повторно. + expect(fetchCampaignsMock).toHaveBeenCalledTimes(2); + }); + + it('paused-кампания показывает кнопку «Возобновить»; клик возобновляет и перезагружает список', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 11, + name: 'На паузе', + status: 'paused', + weekly_budget_rub: '1500.00', + audience_days: 10, + launched_at: '2026-07-18T10:00:00Z', + }, + ]); + resumeCampaignMock.mockResolvedValue({ status: 'running' }); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const resumeBtn = w.find('[data-testid="campaign-resume-btn"]'); + expect(resumeBtn.exists()).toBe(true); + expect(w.find('[data-testid="campaign-pause-btn"]').exists()).toBe(false); + + await resumeBtn.trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(resumeCampaignMock).toHaveBeenCalledWith(11); + expect(fetchCampaignsMock).toHaveBeenCalledTimes(2); + }); + + it('draft-кампания не показывает ни «Пауза», ни «Возобновить»', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 12, + name: 'Черновик', + status: 'draft', + weekly_budget_rub: '500.00', + audience_days: 7, + launched_at: null, + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="campaign-pause-btn"]').exists()).toBe(false); + expect(w.find('[data-testid="campaign-resume-btn"]').exists()).toBe(false); + }); + + it('T16 — имя карточки показывает id кампании (различить одинаково названные черновики)', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 21, + name: 'Реклама от 25.07.2026', + status: 'draft', + weekly_budget_rub: '1000.00', + audience_days: 7, + launched_at: null, + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="campaign-name"]').text()).toBe('Реклама от 25.07.2026 #21'); + }); + + it('T15 — у draft-кампании есть кнопка «Удалить»; у не-draft — нет', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 30, + name: 'Черновик', + status: 'draft', + weekly_budget_rub: '500.00', + audience_days: 7, + launched_at: null, + }, + { + id: 31, + name: 'Крутится', + status: 'running', + weekly_budget_rub: '2000.00', + audience_days: 10, + launched_at: '2026-07-20T10:00:00Z', + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const deleteBtns = w.findAll('[data-testid="campaign-delete-btn"]'); + expect(deleteBtns).toHaveLength(1); + }); + + it('T15 — клик «Удалить» просит подтверждение; после подтверждения зовёт deleteCampaign и перезагружает список', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 40, + name: 'На удаление', + status: 'draft', + weekly_budget_rub: '500.00', + audience_days: 7, + launched_at: null, + }, + ]); + deleteCampaignMock.mockResolvedValue(undefined); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-delete-btn"]').trigger('click'); + await w.vm.$nextTick(); + + // Подтверждение — deleteCampaign ещё НЕ вызван просто по клику на «Удалить». + expect(deleteCampaignMock).not.toHaveBeenCalled(); + + await w.find('[data-testid="campaign-delete-confirm"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(deleteCampaignMock).toHaveBeenCalledWith(40); + expect(fetchCampaignsMock).toHaveBeenCalledTimes(2); + }); + + it('T15 — «Отмена» в диалоге подтверждения не зовёт deleteCampaign', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 41, + name: 'Передумали', + status: 'draft', + weekly_budget_rub: '500.00', + audience_days: 7, + launched_at: null, + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-delete-btn"]').trigger('click'); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-delete-cancel"]').trigger('click'); + await w.vm.$nextTick(); + + expect(deleteCampaignMock).not.toHaveBeenCalled(); + }); + + it('клик «Отчёт» открывает диалог отчёта кампании', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 13, + name: 'Для отчёта', + status: 'running', + weekly_budget_rub: '2500.00', + audience_days: 10, + launched_at: '2026-07-20T10:00:00Z', + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.findComponent({ name: 'CampaignReportDialog' }).exists()).toBe(true); + expect(w.findComponent({ name: 'CampaignReportDialog' }).props('modelValue')).toBe(false); + + await w.find('[data-testid="campaign-report-btn"]').trigger('click'); + await w.vm.$nextTick(); + + expect(w.findComponent({ name: 'CampaignReportDialog' }).props('modelValue')).toBe(true); + expect(w.findComponent({ name: 'CampaignReportDialog' }).props('campaignId')).toBe(13); + }); +}); + +describe('CampaignList.vue — подпись под ярлыком «Отклонено»', () => { + beforeEach(() => { + fetchCampaignsMock.mockReset(); + }); + + /** + * До 28.07.2026 клиент видел красный ярлык «Отклонено» и ни слова о том, что + * переделывать: причина лежала в базе и на экран не отдавалась. + */ + it('у отклонённой кампании под ярлыком видна первая строка причины', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 7, + name: 'Директора', + status: 'rejected', + audience_days: 30, + launched_at: null, + moderation_reason: 'Изображение не подошло\nвторая строка подробностей', + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + const reason = w.find('[data-testid="campaign-rejection-reason"]'); + expect(reason.exists()).toBe(true); + expect(reason.text()).toBe('Изображение не подошло'); + }); + + /** + * 🔴 Настоящие ответы Яндекса начинаются с переноса строки — проверено живьём + * 28.07.2026: StatusClarification = "\nОтклонено на модерации.". Подпись бралась + * как первая строка, первая строка оказывалась пустой, и блок исчезал совсем: + * клиент видел ярлык «Отклонено» и ничего больше. Чинится и на сервере, и здесь — + * два слоя, потому что текст может прийти и из другого места. + */ + it('причина с ведущим переносом всё равно видна клиенту', async () => { + fetchCampaignsMock.mockResolvedValue([ + { + id: 9, + name: 'Директора', + status: 'rejected', + audience_days: 30, + launched_at: null, + moderation_reason: '\n Нет предупреждения: финансовые услуги\nподробности ниже', + }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + const reason = w.find('[data-testid="campaign-rejection-reason"]'); + expect(reason.exists()).toBe(true); + expect(reason.text()).toBe('Нет предупреждения: финансовые услуги'); + }); + + it('у работающей кампании подписи с причиной нет', async () => { + fetchCampaignsMock.mockResolvedValue([ + { id: 8, name: 'Крутится', status: 'running', audience_days: 30, launched_at: null, moderation_reason: null }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="campaign-rejection-reason"]').exists()).toBe(false); + }); +}); + +describe('CampaignList.vue — кнопка «Исправить» оживляет кампанию', () => { + beforeEach(() => { + fetchCampaignsMock.mockReset(); + reviveCampaignMock.mockReset(); + }); + + const rejected = [ + { + id: 21, + name: 'Директора', + status: 'rejected', + audience_days: 30, + launched_at: null, + moderation_reason: 'Изображение не подошло', + }, + ]; + + /** + * До 28.07.2026 «Исправить» открывала мастер, а сервер запрещал менять всё, кроме + * названия: кнопка вела в тупик. Теперь она сперва возвращает кампанию в черновик. + */ + it('у отклонённой кампании нажатие сначала оживляет, потом открывает мастер', async () => { + fetchCampaignsMock.mockResolvedValue(rejected); + reviveCampaignMock.mockResolvedValue(undefined); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-edit-btn"]').trigger('click'); + await flush(); + + expect(reviveCampaignMock).toHaveBeenCalledWith(21); + expect(w.emitted('edit')?.[0]).toEqual([21]); + }); + + /** Черновик оживлять нечего — «Изменить» обязано открывать мастер напрямую. */ + it('у черновика оживление не зовётся', async () => { + fetchCampaignsMock.mockResolvedValue([ + { id: 22, name: 'Черновик', status: 'draft', audience_days: 30, launched_at: null }, + ]); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-edit-btn"]').trigger('click'); + await flush(); + + expect(reviveCampaignMock).not.toHaveBeenCalled(); + expect(w.emitted('edit')?.[0]).toEqual([22]); + }); + + /** + * Оживление не вышло — мастер открывать нельзя: сервер всё равно запретит правку, + * и клиент увидит непонятный отказ уже внутри мастера. + */ + it('если оживление не вышло — мастер не открывается, видна ошибка', async () => { + fetchCampaignsMock.mockResolvedValue(rejected); + reviveCampaignMock.mockRejectedValue(new Error('нельзя')); + + const w = mount(CampaignList, { global: { plugins: [vuetify], stubs: DIALOG_STUBS } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="campaign-edit-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.emitted('edit')).toBeUndefined(); + expect(w.find('[data-testid="campaign-action-error"]').exists()).toBe(true); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-messages.spec.ts b/app/tests/Frontend/advertising-campaign-messages.spec.ts new file mode 100644 index 00000000..c5f0304f --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-messages.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * CampaignMessages — окно передачи между Яндексом и клиентом по одной кампании + * (замысел 2026-07-28-yandex-otkazy-okno-peredachi-design.md, кусок 1). + */ +const { fetchMessagesMock, sendMessageMock } = vi.hoisted(() => ({ + fetchMessagesMock: vi.fn(), + sendMessageMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchCampaignMessages: fetchMessagesMock, + sendCampaignMessage: sendMessageMock, +})); + +import CampaignMessages from '../../resources/js/components/advertising/CampaignMessages.vue'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('CampaignMessages.vue — переписка по кампании', () => { + beforeEach(() => { + fetchMessagesMock.mockReset(); + sendMessageMock.mockReset(); + }); + + it('показывает слова Яндекса и ответ клиента как есть', async () => { + fetchMessagesMock.mockResolvedValue([ + { id: 1, author: 'yandex', banner_id: null, body: 'Изображение не подошло', file_name: null, file_size: null, created_at: null }, + { id: 2, author: 'client', banner_id: null, body: 'Исправили', file_name: 'akt.pdf', file_size: 100, created_at: null }, + ]); + + const w = mount(CampaignMessages, { + props: { campaignId: 13 }, + global: { plugins: [vuetify] }, + }); + await flush(); + await w.vm.$nextTick(); + + expect(fetchMessagesMock).toHaveBeenCalledWith(13); + + const items = w.findAll('[data-testid="campaign-message"]'); + expect(items).toHaveLength(2); + expect(items[0].text()).toContain('Яндекс'); + expect(items[0].text()).toContain('Изображение не подошло'); + expect(items[1].text()).toContain('Вы'); + expect(w.find('[data-testid="campaign-message-file"]').text()).toBe('akt.pdf'); + }); + + /** + * Лента живёт внутри диалога отчёта. Если она не загрузилась, клиент должен увидеть + * об этом строчку, а не пустоту, которую не отличить от «сообщений нет». + * И уж точно не должен падать весь отчёт. + */ + it('упавшая загрузка не роняет экран и говорит об этом словами', async () => { + fetchMessagesMock.mockRejectedValue(new Error('сеть')); + + const w = mount(CampaignMessages, { + props: { campaignId: 13 }, + global: { plugins: [vuetify] }, + }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="campaign-messages"]').exists()).toBe(true); + expect(w.text()).toContain('Не получилось загрузить переписку'); + }); + + it('отправляет ответ и перечитывает ленту', async () => { + fetchMessagesMock.mockResolvedValue([]); + sendMessageMock.mockResolvedValue(undefined); + + const w = mount(CampaignMessages, { + props: { campaignId: 13 }, + global: { plugins: [vuetify] }, + }); + await flush(); + + await w.find('[data-testid="campaign-message-input"] textarea').setValue('Приложил лицензию'); + await w.find('[data-testid="campaign-message-send"]').trigger('click'); + await flush(); + + expect(sendMessageMock).toHaveBeenCalledWith(13, 'Приложил лицензию', null); + expect(fetchMessagesMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts b/app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts new file mode 100644 index 00000000..510ea65f --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * CampaignReportDialog под модель «за показы» (Ч.6, Task 3). + * + * Диалог должен показывать показы (оплачено/показано/частота) + потрачено + * (списанное по факту CampaignImpressionCharger), метку статуса queued/completed, + * и НЕ показывать старый недельный клик-бюджет / нашу маржу (yandex_cost) / цену + * за клик — клиент их не видит (Р35, $hidden на модели). + */ +const { fetchCampaignMock } = vi.hoisted(() => ({ + fetchCampaignMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchCampaign: fetchCampaignMock, + // Диалог отчёта теперь показывает ленту переписки вместо мёртвого списка + // ad_campaign_ads — её загрузку тоже надо подменить, иначе mount падает. + fetchCampaignMessages: vi.fn().mockResolvedValue([]), + sendCampaignMessage: vi.fn().mockResolvedValue(undefined), +})); + +import CampaignReportDialog from '../../resources/js/components/advertising/CampaignReportDialog.vue'; + +const vuetify = createVuetify(); + +const DIALOG_STUBS = { + VDialog: { + template: '
', + props: ['modelValue'], + }, +}; + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('CampaignReportDialog.vue (Ч.6 Task 3 — отчёт по показам)', () => { + beforeEach(() => { + fetchCampaignMock.mockReset(); + }); + + it('completed-кампания показов: показывает показано/частоту/потрачено + метку «Показы откручены», без weekly/yandex_cost/клика', async () => { + fetchCampaignMock.mockResolvedValue({ + campaign: { + id: 77, + name: 'Директора Казани', + status: 'completed', + audience_days: 30, + launched_at: '2026-07-01T10:00:00Z', + estimated_impressions: 5000, + delivered_impressions: 5000, + frequency: 5, + }, + ads: [], + spent_rub: '600.00', + }); + + const w = mount(CampaignReportDialog, { + props: { modelValue: false, campaignId: 77 }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.setProps({ modelValue: true }); + await flush(); + await w.vm.$nextTick(); + + expect(fetchCampaignMock).toHaveBeenCalledWith(77); + + // Метка статуса. + expect(w.text()).toContain('Показы откручены'); + + // Показано. + const delivered = w.find('[data-testid="report-impressions-delivered"]'); + expect(delivered.exists()).toBe(true); + expect(delivered.text()).toBe('5000'); + + // Показов оплачено (смета). + const paid = w.find('[data-testid="report-impressions-paid"]'); + expect(paid.exists()).toBe(true); + expect(paid.text()).toBe('5000'); + + // Частота. + const frequency = w.find('[data-testid="report-frequency"]'); + expect(frequency.exists()).toBe(true); + expect(frequency.text()).toBe('5'); + + // Потрачено. + const spent = w.find('[data-testid="report-spent"]'); + expect(spent.exists()).toBe(true); + expect(spent.text()).toBe(`${new Intl.NumberFormat('ru-RU').format(600)} ₽`); + + // 🔴 Клиент не видит маржу/цену за клик/старый недельный бюджет. + expect(w.text()).not.toMatch(/Бюджет в неделю/); + expect(w.text()).not.toMatch(/weekly/i); + expect(w.text()).not.toMatch(/yandex_cost/i); + expect(w.text()).not.toMatch(/клик/i); + }); + + it('queued-кампания: метка «Готова к запуску», пустые показы/частота — «—», не роняет', async () => { + fetchCampaignMock.mockResolvedValue({ + campaign: { + id: 78, + name: 'Черновик показов', + status: 'queued', + audience_days: 14, + launched_at: null, + }, + ads: [], + spent_rub: '0.00', + }); + + const w = mount(CampaignReportDialog, { + props: { modelValue: true, campaignId: 78 }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await flush(); + await w.vm.$nextTick(); + + expect(w.text()).toContain('Готова к запуску'); + expect(w.find('[data-testid="report-impressions-delivered"]').text()).toBe('—'); + expect(w.find('[data-testid="report-impressions-paid"]').text()).toBe('—'); + expect(w.find('[data-testid="report-frequency"]').text()).toBe('—'); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-report.spec.ts b/app/tests/Frontend/advertising-campaign-report.spec.ts new file mode 100644 index 00000000..40dbbba4 --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-report.spec.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * CampaignReportDialog (Часть B2, задача T2) — диалог отчёта по кампании. + * + * Показывает ТОЛЬКО реально доступные данные из GET /api/advertising/campaigns/{id}: + * потрачено (spent_rub) и статусы объявлений. Показы/клики/охват — НЕ выдумываем, + * их не отдаёт backend (см. задачу T2). + */ +const { fetchCampaignMock } = vi.hoisted(() => ({ + fetchCampaignMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchCampaign: fetchCampaignMock, + // Диалог отчёта теперь показывает ленту переписки вместо мёртвого списка + // ad_campaign_ads — её загрузку тоже надо подменить, иначе mount падает. + fetchCampaignMessages: vi.fn().mockResolvedValue([]), + sendCampaignMessage: vi.fn().mockResolvedValue(undefined), +})); + +import CampaignReportDialog from '../../resources/js/components/advertising/CampaignReportDialog.vue'; + +const vuetify = createVuetify(); + +/** + * v-dialog требует layout-injection (v-app/v-layout) — недоступно в изолированном + * mount() без полноценного приложения. Stub'им VDialog как passthrough div + * (тот же приём, что и в ImpersonationDialog.spec.ts). + */ +const DIALOG_STUBS = { + VDialog: { + template: '
', + props: ['modelValue'], + }, +}; + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('CampaignReportDialog.vue (T2 — отчёт по кампании)', () => { + beforeEach(() => { + fetchCampaignMock.mockReset(); + }); + + it('при открытии зовёт fetchCampaign(campaignId) и показывает потрачено + переписку', async () => { + fetchCampaignMock.mockResolvedValue({ + campaign: { + id: 13, + name: 'Директора СПб', + status: 'running', + weekly_budget_rub: '5000.00', + audience_days: 30, + launched_at: '2026-07-20T10:00:00Z', + }, + ads: [ + { + id: 1, + campaign_id: 13, + title: 'Окна со скидкой', + text: 'Т', + href: 'https://x.ru', + title2: null, + moderation_status: 'accepted', + }, + { + id: 2, + campaign_id: 13, + title: 'Второе объявление', + text: 'Т2', + href: 'https://x.ru', + title2: null, + moderation_status: 'rejected', + moderation_reason: 'Нет лицензии в тексте', + }, + ], + spent_rub: '1234.00', + }); + + const w = mount(CampaignReportDialog, { + props: { modelValue: false, campaignId: 13 }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + expect(fetchCampaignMock).not.toHaveBeenCalled(); + + await w.setProps({ modelValue: true }); + await flush(); + await w.vm.$nextTick(); + + expect(fetchCampaignMock).toHaveBeenCalledWith(13); + + expect(w.text()).toContain('Директора СПб'); + + const spent = w.find('[data-testid="report-spent"]'); + expect(spent.exists()).toBe(true); + expect(spent.text()).toBe(`${new Intl.NumberFormat('ru-RU').format(1234)} ₽`); + + // Список detail.ads из диалога убран намеренно (28.07.2026): таблица + // ad_campaign_ads осталась от старой модели с текстовыми объявлениями, + // поток «за показы» её не заполняет — клиент всегда видел «Объявлений пока + // нет» рядом с ярлыком «Отклонено» и ни слова о причине. Вместо мёртвого + // списка в диалоге живёт переписка по кампании. + expect(w.findAll('[data-testid="report-ad"]')).toHaveLength(0); + expect(w.find('[data-testid="campaign-messages"]').exists()).toBe(true); + + // Показы/клики/охват НЕ выдумываем как метрики (лейблы "Показы"/"Клики"/"Охват") — + // их нет в ответе В8. Честная строка-дисклеймер про будущую статистику разрешена. + expect(w.text()).not.toMatch(/Показы|Клики|Охват/); + }); + + it('во время загрузки показывает прогресс, после ошибки — человекочитаемое сообщение', async () => { + fetchCampaignMock.mockRejectedValue(new Error('network fail')); + + const w = mount(CampaignReportDialog, { + props: { modelValue: true, campaignId: 20 }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + expect(w.findComponent({ name: 'VProgressCircular' }).exists()).toBe(true); + + await flush(); + await w.vm.$nextTick(); + + expect(w.text()).toContain('Произошла ошибка. Попробуйте позже.'); + }); + + it('campaignId=null не зовёт fetchCampaign, даже если диалог открыт', async () => { + mount(CampaignReportDialog, { + props: { modelValue: true, campaignId: null }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + await flush(); + expect(fetchCampaignMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts b/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts new file mode 100644 index 00000000..cb4fe549 --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts @@ -0,0 +1,737 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +const { + createCampaignMock, + fetchAudienceSizeMock, + patchCampaignMock, + fetchCampaignMock, + fetchCampaignsMock, + uploadCampaignPhonesMock, + fetchBannersMock, + uploadBannerMock, + toggleBannerIncludedMock, + deleteBannerMock, + approveBannersMock, + submitCampaignMock, +} = vi.hoisted(() => ({ + createCampaignMock: vi.fn(), + fetchAudienceSizeMock: vi.fn(), + patchCampaignMock: vi.fn(), + fetchCampaignMock: vi.fn(), + fetchCampaignsMock: vi.fn(), + uploadCampaignPhonesMock: vi.fn(), + fetchBannersMock: vi.fn(), + uploadBannerMock: vi.fn(), + toggleBannerIncludedMock: vi.fn(), + deleteBannerMock: vi.fn(), + approveBannersMock: vi.fn(), + submitCampaignMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + createCampaign: createCampaignMock, + fetchAudienceSize: fetchAudienceSizeMock, + patchCampaign: patchCampaignMock, + fetchCampaign: fetchCampaignMock, + fetchCampaigns: fetchCampaignsMock, + uploadCampaignPhones: uploadCampaignPhonesMock, + fetchBanners: fetchBannersMock, + uploadBanner: uploadBannerMock, + toggleBannerIncluded: toggleBannerIncludedMock, + deleteBanner: deleteBannerMock, + approveBanners: approveBannersMock, + submitCampaign: submitCampaignMock, +})); + +/** + * Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`), + * с нужным `response.data.message` — см. AdminBillingViewActions.spec.ts. + */ +function makeAxiosError(message: string, status = 422): unknown { + return Object.assign(new Error(message), { + isAxiosError: true, + response: { status, data: { message } }, + }); +} + +import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue'; +import type { BannerSlot } from '../../resources/js/api/advertising'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +const DRAFT_CAMPAIGN = { + id: 99, + name: 'Реклама от 25.07.2026', + status: 'draft' as const, + audience_days: 10, + launched_at: null, +}; + +const ESTIMATE = { + size: 1630, + min: 100, + enough: true, + hint: null, + frequency: 5, + impressions: 8150, + cpm_rub: '120.00', + cost_rub: '978.00', +}; + +/** Те же 15 канонических размеров, что и в BannerSizes::all() (backend). */ +const BANNER_SIZES: Array<[number, number]> = [ + [160, 600], + [240, 400], + [240, 600], + [300, 250], + [300, 300], + [300, 500], + [300, 600], + [320, 480], + [336, 280], + [480, 320], + [728, 90], + [970, 250], + [320, 50], + [320, 100], + [1000, 120], +]; + +function emptySlot(w: number, h: number): BannerSlot { + return { width: w, height: h, uploaded: false, banner_id: null, bytes: null, included: false, preview_url: null }; +} + +function uploadedSlot(w: number, h: number, bannerId: number, included = true): BannerSlot { + return { + width: w, + height: h, + uploaded: true, + banner_id: bannerId, + bytes: 20000, + included, + preview_url: `/api/advertising/campaigns/99/banners/${bannerId}/preview`, + }; +} + +/** Собирает полный ответ GET .../banners (все 15 слотов), часть — заранее загружена. */ +function bannerSetFixture(opts: { + approvedAt?: string | null; + uploaded?: Array<{ w: number; h: number; id: number; included?: boolean }>; +} = {}) { + const uploadedMap = new Map((opts.uploaded ?? []).map((u) => [`${u.w}x${u.h}`, u])); + return { + approved_at: opts.approvedAt ?? null, + max_bytes: 153600, + formats: ['jpg', 'jpeg', 'png', 'gif'], + slots: BANNER_SIZES.map(([w, h]) => { + const u = uploadedMap.get(`${w}x${h}`); + return u ? uploadedSlot(w, h, u.id, u.included ?? true) : emptySlot(w, h); + }), + }; +} + +const BANNERS_EMPTY = bannerSetFixture(); +const BANNERS_TWO_UPLOADED = bannerSetFixture({ + uploaded: [ + { w: 300, h: 250, id: 1 }, + { w: 728, h: 90, id: 2 }, + ], +}); +const BANNERS_APPROVED = bannerSetFixture({ + approvedAt: '2026-07-26T00:00:00Z', + uploaded: [ + { w: 300, h: 250, id: 1 }, + { w: 728, h: 90, id: 2 }, + ], +}); + +async function gotoStep2(w: ReturnType): Promise { + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true); +} + +async function gotoStep3(w: ReturnType): Promise { + await gotoStep2(w); + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true); +} + +async function gotoStep4(w: ReturnType): Promise { + await gotoStep3(w); + (w.vm as unknown as { bannersApprovedAt: string | null }).bannersApprovedAt = '2026-07-26T00:00:00Z'; + (w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/promo'; + await w.vm.$nextTick(); + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true); +} + +describe('CampaignWizard.vue (Ч.5b — показы, шаг 2 «Как часто показывать»)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue(ESTIMATE); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + it('зовёт fetchAudienceSize(id, days, frequency) и рендерит «120 ₽ за 1000 показов» + сумму, без клик-текста/маржи', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + await gotoStep2(w); + + expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, { mode: 'auto', frequency: 5, days: 10 }); + + const step2 = w.find('[data-testid="wizard-step-2"]'); + expect(step2.text()).toContain('120 ₽ за 1000 показов'); + expect(step2.text()).toContain('978'); + + const fullText = w.text(); + expect(fullText).not.toContain('Цена за клик'); + expect(fullText).not.toContain('yandex_cost'); + expect(fullText).not.toContain('380'); + }); + + it('цена берётся из audienceSize.cpm_rub с сервера, а не хардкодится', async () => { + fetchAudienceSizeMock.mockResolvedValue({ ...ESTIMATE, cpm_rub: '90.00', cost_rub: '733.50' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + const step2 = w.find('[data-testid="wizard-step-2"]'); + expect(step2.text()).toContain('90 ₽ за 1000 показов'); + expect(step2.text()).not.toContain('120 ₽ за 1000 показов'); + }); + + it('смена частоты пересчитывает смету через refreshAudienceSize', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + fetchAudienceSizeMock.mockResolvedValue({ ...ESTIMATE, frequency: 20, impressions: 32600, cost_rub: '3912.00' }); + (w.vm as unknown as { form: { frequency: number } }).form.frequency = 20; + await (w.vm as unknown as { refreshAudienceSize: () => Promise }).refreshAudienceSize(); + await w.vm.$nextTick(); + + expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, { mode: 'auto', frequency: 20, days: 10, cpm: '120.00' }); + expect(w.find('[data-testid="wizard-step-2"]').text()).toContain('912'); + }); + + it('уход с шага 2 сохраняет частоту/показы/бюджет через patchCampaign', async () => { + patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(patchCampaignMock).toHaveBeenCalledWith(99, { + frequency: 5, + frequency_period_days: 10, + estimated_impressions: 8150, + budget_rub: '978.00', + client_cpm_rub: '120.00', + }); + expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true); + }); +}); + +describe('CampaignWizard.vue (Ч.5b3c — показы, шаг 3 «Баннеры» — сетка 15 слотов, свой файл на каждый размер)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue(ESTIMATE); + patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + uploadBannerMock.mockReset(); + toggleBannerIncludedMock.mockReset(); + deleteBannerMock.mockReset(); + fetchBannersMock.mockReset(); + approveBannersMock.mockReset(); + }); + + it('рендерятся все 15 слотов, каждый со своим размером и лимитом «до 150 КБ»', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + const slots = w.findAll('[data-testid="banner-slot"]'); + expect(slots).toHaveLength(15); + + for (const [width, height] of BANNER_SIZES) { + expect(w.find('[data-testid="wizard-step-3"]').text()).toContain(`${width}×${height}`); + } + expect(w.find('[data-testid="wizard-step-3"]').text()).toContain('до 150 КБ'); + }); + + it('кнопка «Утвердить» скрыта пока не загружен ни один баннер, появляется после загрузки первого', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + expect(w.find('[data-testid="banner-approve-btn"]').exists()).toBe(false); + + const slot = emptySlot(300, 250); + const file = new File(['x'], 'a.jpg', { type: 'image/jpeg' }); + uploadBannerMock.mockResolvedValue({ slot: uploadedSlot(300, 250, 1) }); + fetchBannersMock.mockResolvedValue(bannerSetFixture({ uploaded: [{ w: 300, h: 250, id: 1 }] })); + + await (w.vm as unknown as { uploadSlot: (s: BannerSlot, f: File | null) => Promise }).uploadSlot(slot, file); + await flush(); + await w.vm.$nextTick(); + + expect(uploadBannerMock).toHaveBeenCalledWith(99, 300, 250, file); + expect(w.find('[data-testid="banner-approve-btn"]').exists()).toBe(true); + }); + + it('у загруженного слота есть превью, галочка «в показ» и кнопка «Удалить»', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + const uploadedCards = w.findAll('[data-testid="banner-slot"][data-uploaded="true"]'); + expect(uploadedCards).toHaveLength(2); + + const previews = w.findAll('[data-testid="banner-preview-img"]'); + expect(previews).toHaveLength(2); + expect(w.findAll('[data-testid="banner-slot-included"]')).toHaveLength(2); + expect(w.findAll('[data-testid="banner-slot-delete"]')).toHaveLength(2); + }); + + it('переключение галочки «в показ» зовёт toggleBannerIncluded и перезагружает набор', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + toggleBannerIncludedMock.mockResolvedValue({ slot: uploadedSlot(300, 250, 1, false) }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + const slot = BANNERS_TWO_UPLOADED.slots.find((s) => s.width === 300 && s.height === 250)!; + await (w.vm as unknown as { toggleSlot: (s: BannerSlot) => Promise }).toggleSlot(slot); + await flush(); + + expect(toggleBannerIncludedMock).toHaveBeenCalledWith(99, 1, false); + }); + + it('«Удалить» зовёт deleteBanner и перезагружает набор', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + deleteBannerMock.mockResolvedValue(undefined); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + const slot = BANNERS_TWO_UPLOADED.slots.find((s) => s.width === 728 && s.height === 90)!; + await (w.vm as unknown as { deleteSlot: (s: BannerSlot) => Promise }).deleteSlot(slot); + await flush(); + + expect(deleteBannerMock).toHaveBeenCalledWith(99, 2); + }); + + it('клик «Утвердить» зовёт approveBanners и показывает «Баннеры утверждены»', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + approveBannersMock.mockResolvedValue({ approved_at: '2026-07-26T00:00:00Z' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + await w.find('[data-testid="banner-approve-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(approveBannersMock).toHaveBeenCalledWith(99); + expect(w.find('[data-testid="banners-approved-note"]').exists()).toBe(true); + }); + + it('без утверждённых баннеров «Далее» не пускает на шаг 4', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true); + expect(w.find('[data-testid="banner-step-error"]').exists()).toBe(true); + }); + + it('выбор файла больше лимита показывает ошибку и НЕ зовёт uploadBanner', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + const slot = emptySlot(300, 250); + const bigFile = new File(['x'], 'big.jpg', { type: 'image/jpeg' }); + Object.defineProperty(bigFile, 'size', { value: 200000, configurable: true }); + + await (w.vm as unknown as { uploadSlot: (s: BannerSlot, f: File | null) => Promise }).uploadSlot(slot, bigFile); + await flush(); + await w.vm.$nextTick(); + + expect(uploadBannerMock).not.toHaveBeenCalled(); + const errorText = w.find('[data-testid="wizard-step-3"]').text(); + expect(errorText).toContain('150 КБ'); + }); + + it('пустой адрес сайта блокирует переход на шаг 4 и показывает подсказку про http', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + approveBannersMock.mockResolvedValue({ approved_at: '2026-07-26T00:00:00Z' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + await w.find('[data-testid="banner-approve-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="wizard-step-3"]').exists()).toBe(true); + const err = w.find('[data-testid="banner-step-error"]'); + expect(err.exists()).toBe(true); + expect(err.text()).toContain('адрес сайта'); + // gotoStep3 уже сохранил шаги 1/2 через patchCampaign (2 вызова) — блокировка на шаге 3 + // не должна добавить третий (с landing_url). + expect(patchCampaignMock).toHaveBeenCalledTimes(2); + }); + + it('заполненный адрес сайта сохраняется через patchCampaign при переходе на шаг 4', async () => { + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + approveBannersMock.mockResolvedValue({ approved_at: '2026-07-26T00:00:00Z' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep3(w); + + await w.find('[data-testid="banner-approve-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + (w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/promo'; + await w.vm.$nextTick(); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(patchCampaignMock).toHaveBeenCalledWith(99, { landing_url: 'https://liderra.ru/promo' }); + expect(w.find('[data-testid="wizard-step-4"]').exists()).toBe(true); + }); +}); + +describe('CampaignWizard.vue (Ч.5b — показы, шаг 4 «Проверка и отправка»)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue(ESTIMATE); + patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + uploadBannerMock.mockReset(); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + approveBannersMock.mockReset(); + submitCampaignMock.mockReset(); + }); + + it('(a) use_uploaded_list=false — согласие не требуется, «Отправить заявку» зовёт submitCampaign и эмитит launched', async () => { + submitCampaignMock.mockResolvedValue({ status: 'queued' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep4(w); + + const summary = w.find('[data-testid="submit-summary"]'); + expect(summary.text()).toContain('1630'); + expect(summary.text()).toContain('5'); + expect(summary.text()).toContain('8150'); + expect(summary.text()).toContain('120 ₽ за 1000 показов'); + expect(summary.text()).toContain('978'); + + expect(w.find('[data-testid="submit-consent"]').exists()).toBe(false); + + const btn = w.find('[data-testid="submit-btn"]'); + expect(btn.attributes('disabled')).toBeUndefined(); + await btn.trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(submitCampaignMock).toHaveBeenCalledWith(99); + expect(w.emitted('launched')).toBeTruthy(); + }); + + it('(b) use_uploaded_list=true — согласие обязательно, кнопка блокируется без галочки', async () => { + submitCampaignMock.mockResolvedValue({ status: 'queued' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + (w.vm as unknown as { form: { use_uploaded_list: boolean } }).form.use_uploaded_list = true; + await w.vm.$nextTick(); + + await gotoStep4(w); + + expect(w.find('[data-testid="submit-consent"]').exists()).toBe(true); + let btn = w.find('[data-testid="submit-btn"]'); + expect(btn.attributes('disabled')).toBeDefined(); + + const consentInput = w.find('[data-testid="submit-consent"] input[type="checkbox"]'); + await consentInput.setValue(true); + await w.vm.$nextTick(); + + btn = w.find('[data-testid="submit-btn"]'); + expect(btn.attributes('disabled')).toBeUndefined(); + + await btn.trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(submitCampaignMock).toHaveBeenCalledWith(99); + expect(w.emitted('launched')).toBeTruthy(); + }); + + it('(c) submitCampaign отклоняется 422 — виден submit-error, launched не эмитится', async () => { + submitCampaignMock.mockRejectedValue(makeAxiosError('Сначала утвердите баннеры.', 422)); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep4(w); + + await w.find('[data-testid="submit-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const err = w.find('[data-testid="submit-error"]'); + expect(err.exists()).toBe(true); + expect(err.text()).toContain('Сначала утвердите баннеры.'); + expect(w.emitted('launched')).toBeFalsy(); + }); + + it('на шаге 4 кнопки «Далее» уже нет — это последний шаг', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep4(w); + + expect(w.find('[data-testid="wizard-next"]').exists()).toBe(false); + expect(w.find('[data-testid="wizard-back"]').exists()).toBe(true); + }); +}); + +describe('CampaignWizard.vue (Ч.5b — шаг 1 без изменений по сути)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue(ESTIMATE); + uploadCampaignPhonesMock.mockReset(); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + it('шаг 1 показывает размер аудитории (авто, по умолчанию), без клик-полей', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true); + expect(w.find('[data-testid="audience-days-slider"]').exists()).toBe(true); + // В авто своего списка нет — переключатель скрыт, пока не выбран ручной режим. + expect(w.find('[data-testid="use-list-switch"]').exists()).toBe(false); + expect(w.text()).not.toContain('Цена за клик'); + }); +}); + +describe('CampaignWizard.vue (Ч.5b/reklama-pokazy — режим сбора аудитории auto/manual)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue(ESTIMATE); + patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + it('по умолчанию режим auto: виден ползунок дней, полей дат/срока и своего списка нет', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="audience-days-slider"]').exists()).toBe(true); + expect(w.find('[data-testid="snapshot-from"]').exists()).toBe(false); + expect(w.find('[data-testid="snapshot-to"]').exists()).toBe(false); + expect(w.find('[data-testid="run-days"]').exists()).toBe(false); + expect(w.find('[data-testid="use-list-switch"]').exists()).toBe(false); + }); + + it('переключение на manual прячет ползунок дней, показывает даты/срок и переключатель своего списка', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="mode-manual"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect((w.vm as unknown as { form: { mode: string } }).form.mode).toBe('manual'); + expect(w.find('[data-testid="audience-days-slider"]').exists()).toBe(false); + expect(w.find('[data-testid="snapshot-from"]').exists()).toBe(true); + expect(w.find('[data-testid="snapshot-to"]').exists()).toBe(true); + expect(w.find('[data-testid="run-days"]').exists()).toBe(true); + expect(w.find('[data-testid="use-list-switch"]').exists()).toBe(true); + }); + + it('manual: без обеих дат «Далее» не пускает на шаг 2 и показывает подсказку', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="mode-manual"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true); + expect(w.find('[data-testid="wizard-step1-error"]').exists()).toBe(true); + expect(patchCampaignMock).not.toHaveBeenCalled(); + }); + + it('manual: с датами и сроком «Далее» сохраняет mode/snapshot_from/snapshot_to/run_days через patchCampaign и пускает на шаг 2', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="mode-manual"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + (w.vm as unknown as { form: { snapshot_from: string; snapshot_to: string; run_days: number } }).form.snapshot_from = + '2026-07-01'; + (w.vm as unknown as { form: { snapshot_to: string } }).form.snapshot_to = '2026-07-15'; + (w.vm as unknown as { form: { run_days: number } }).form.run_days = 7; + await w.vm.$nextTick(); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(patchCampaignMock).toHaveBeenCalledWith(99, { + mode: 'manual', + audience_days: 10, + use_uploaded_list: false, + snapshot_from: '2026-07-01', + snapshot_to: '2026-07-15', + run_days: 7, + }); + expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true); + }); + + it('цена на шаге 2 предзаполняется из cpm_rub сервера, а изменение шлёт cpm в fetchAudienceSize', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + expect((w.vm as unknown as { form: { client_cpm_rub: string | null } }).form.client_cpm_rub).toBe('120.00'); + const priceInput = w.find('[data-testid="client-cpm-input"]'); + expect(priceInput.exists()).toBe(true); + + fetchAudienceSizeMock.mockResolvedValue({ ...ESTIMATE, cpm_rub: '95.00', cost_rub: '774.25' }); + (w.vm as unknown as { form: { client_cpm_rub: string | null } }).form.client_cpm_rub = '95.00'; + await (w.vm as unknown as { refreshAudienceSize: () => Promise }).refreshAudienceSize(); + await w.vm.$nextTick(); + + expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, { mode: 'auto', frequency: 5, days: 10, cpm: '95.00' }); + }); + + it('шаг 4: сводка показывает «Режим» и, для manual, период снимка + срок показа', async () => { + patchCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + fetchBannersMock.mockResolvedValue(BANNERS_TWO_UPLOADED); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + await w.find('[data-testid="mode-manual"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + (w.vm as unknown as { form: { snapshot_from: string; snapshot_to: string; run_days: number } }).form.snapshot_from = + '2026-07-01'; + (w.vm as unknown as { form: { snapshot_to: string } }).form.snapshot_to = '2026-07-15'; + (w.vm as unknown as { form: { run_days: number } }).form.run_days = 7; + await w.vm.$nextTick(); + + await gotoStep4(w); + + const summary = w.find('[data-testid="submit-summary"]'); + expect(summary.text()).toContain('Разовый запуск'); + expect(summary.find('[data-testid="summary-snapshot-range"]').text()).toContain('2026-07-01'); + expect(summary.find('[data-testid="summary-snapshot-range"]').text()).toContain('2026-07-15'); + expect(summary.find('[data-testid="summary-run-days"]').text()).toContain('7'); + }); +}); diff --git a/app/tests/Frontend/advertising-campaign-wizard.spec.ts b/app/tests/Frontend/advertising-campaign-wizard.spec.ts new file mode 100644 index 00000000..7fc3a6f5 --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-wizard.spec.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * Ч.5b — модель «за клики» (Бюджет/Объявления/launchCampaign) заменена моделью «за показы». + * Клик-специфичные шаги (B2-7 «Бюджет», B2-8 «Проверка и запуск» с launchCampaign) переехали + * в tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts (шаги 2 «Как часто показывать», + * 3 «Баннер», 4 «Проверка и отправка»). Здесь остаётся то, что НЕ поменялось по сути — шаг 1 + * «Кому показываем» (аудитория/список номеров), переиспользование черновика (T14), режим + * правки (B2-9) и загрузка «моего списка номеров» (T18). + */ +const { + createCampaignMock, + fetchAudienceSizeMock, + patchCampaignMock, + fetchCampaignMock, + fetchCampaignsMock, + uploadCampaignPhonesMock, + fetchBannersMock, + uploadBannerMock, + toggleBannerIncludedMock, + deleteBannerMock, + approveBannersMock, + submitCampaignMock, +} = vi.hoisted(() => ({ + createCampaignMock: vi.fn(), + fetchAudienceSizeMock: vi.fn(), + patchCampaignMock: vi.fn(), + // B2-9 — правка на ходу: fetchCampaign(id) грузит существующую кампанию. + fetchCampaignMock: vi.fn(), + // T14 — переиспользование черновика: без campaignId мастер сперва смотрит список. + fetchCampaignsMock: vi.fn(), + // T18 — загрузка «моего списка номеров» с шага 1. + uploadCampaignPhonesMock: vi.fn(), + // Не используются напрямую этими тестами — нужны, чтобы импорт компонента не падал + // (loadBanners() зовётся автоматически при создании/загрузке черновика). + fetchBannersMock: vi.fn(), + uploadBannerMock: vi.fn(), + toggleBannerIncludedMock: vi.fn(), + deleteBannerMock: vi.fn(), + approveBannersMock: vi.fn(), + submitCampaignMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + createCampaign: createCampaignMock, + fetchAudienceSize: fetchAudienceSizeMock, + patchCampaign: patchCampaignMock, + fetchCampaign: fetchCampaignMock, + fetchCampaigns: fetchCampaignsMock, + uploadCampaignPhones: uploadCampaignPhonesMock, + fetchBanners: fetchBannersMock, + uploadBanner: uploadBannerMock, + toggleBannerIncluded: toggleBannerIncludedMock, + deleteBanner: deleteBannerMock, + approveBanners: approveBannersMock, + submitCampaign: submitCampaignMock, +})); + +/** Пустой набор слотов — эти тесты не проверяют шаг «Баннеры», нужна только валидная форма ответа. */ +const BANNERS_EMPTY = { approved_at: null, max_bytes: 153600, formats: ['jpg', 'jpeg', 'png', 'gif'], slots: [] }; + +import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +const DRAFT_CAMPAIGN = { + id: 99, + name: 'Реклама от 25.07.2026', + status: 'draft' as const, + audience_days: 10, + launched_at: null, +}; + +describe('CampaignWizard.vue (шаг 1 «Кому показываем» — каркас + аудитория)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + // T14 — по умолчанию черновиков нет, мастер идёт прежним путём (createDraft). + fetchCampaignsMock.mockResolvedValue([]); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + it('при монтировании создаёт черновик кампании один раз и показывает шаг 1', async () => { + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(createCampaignMock).toHaveBeenCalledTimes(1); + expect(w.find('[data-testid="wizard"]').exists()).toBe(true); + expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true); + // Заголовки шагов модели «за показы» (Ч.5b). + const text = w.text(); + expect(text).toContain('Кому показываем'); + expect(text).toContain('Как часто показывать'); + expect(text).toContain('Баннер'); + expect(text).toContain('Проверка и отправка'); + }); + + it('пересчёт счётчика зовёт fetchAudienceSize(id, days, frequency); ниже 100 показывает подсказку', async () => { + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue({ + size: 42, + min: 100, + enough: false, + hint: 'Маловато — увеличьте до 20 дней', + }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + // Черновик создан с id 99, дефолтные 10 дней и частота 5 — счётчик уже посчитан один раз. + expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, { mode: 'auto', frequency: 5, days: 10 }); + + const hint = w.find('[data-testid="audience-hint"]'); + expect(hint.exists()).toBe(true); + expect(hint.text()).toContain('Маловато'); + + // Меняем срез дней и зовём пересчёт напрямую (без ожидания реального debounce). + (w.vm as unknown as { form: { audience_days: number } }).form.audience_days = 4; + await (w.vm as unknown as { refreshAudienceSize: () => Promise }).refreshAudienceSize(); + await w.vm.$nextTick(); + + expect(fetchAudienceSizeMock).toHaveBeenCalledWith(99, { mode: 'auto', frequency: 5, days: 4 }); + }); + + it('достаточно аудитории — зелёная галочка и результат без подсказки', async () => { + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const result = w.find('[data-testid="audience-size-result"]'); + expect(result.text()).toContain('Наберётся ~120'); + expect(w.find('[data-testid="audience-hint"]').exists()).toBe(false); + }); + + it('кнопки «Назад»/«Далее» переключают шаги мастера', async () => { + createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN); + fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true); + + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true); + + await w.find('[data-testid="wizard-back"]').trigger('click'); + await w.vm.$nextTick(); + expect(w.find('[data-testid="wizard-step-1"]').exists()).toBe(true); + }); +}); + +describe('CampaignWizard.vue (B2-9 — правка на ходу, Р30)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + const EXISTING_CAMPAIGN = { + id: 42, + name: 'Реклама от 01.07.2026', + status: 'running' as const, + audience_days: 20, + frequency: 15, + launched_at: '2026-07-01T10:00:00Z', + use_uploaded_list: true, + }; + + it('(a) с campaignId=42 грузит fetchCampaign, НЕ создаёт черновик, префиллит поля, виден wizard-edit-hint', async () => { + fetchCampaignMock.mockResolvedValue({ + campaign: EXISTING_CAMPAIGN, + ads: [], + spent_rub: '120.00', + }); + fetchAudienceSizeMock.mockResolvedValue({ size: 340, min: 100, enough: true, hint: null }); + + const w = mount(CampaignWizard, { + props: { campaignId: 42 }, + global: { plugins: [vuetify] }, + }); + await flush(); + await w.vm.$nextTick(); + + expect(fetchCampaignMock).toHaveBeenCalledWith(42); + expect(createCampaignMock).not.toHaveBeenCalled(); + expect((w.vm as unknown as { draftId: number | null }).draftId).toBe(42); + + // Подсказка Р30 видна в режиме правки на шаге 1. + const hint = w.find('[data-testid="wizard-edit-hint"]'); + expect(hint.exists()).toBe(true); + expect(hint.text()).toContain('Изменения в списке подхватятся завтра'); + + // Заголовок мастера — «Изменить кампанию», не «Новая реклама». + expect(w.text()).toContain('Изменить кампанию'); + + // Идём на шаг 2 — частота префиллена из ответа fetchCampaign. + await w.find('[data-testid="wizard-next"]').trigger('click'); + await w.vm.$nextTick(); + + expect((w.vm as unknown as { form: { frequency: number } }).form.frequency).toBe(15); + }); + + it('(b) без campaignId и без черновиков в списке — createCampaign зовётся, wizard-edit-hint не виден (T14)', async () => { + createCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null }); + // beforeEach уже даёт fetchCampaignsMock -> [] (черновиков нет). + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(fetchCampaignsMock).toHaveBeenCalledTimes(1); + expect(createCampaignMock).toHaveBeenCalledTimes(1); + expect(fetchCampaignMock).not.toHaveBeenCalled(); + expect(w.find('[data-testid="wizard-edit-hint"]').exists()).toBe(false); + expect(w.text()).not.toContain('Изменить кампанию'); + }); +}); + +describe('CampaignWizard.vue (T14 — переиспользование черновика вместо плодения новых)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + const EXISTING_DRAFT = { + id: 77, + name: 'Реклама от 24.07.2026', + status: 'draft' as const, + audience_days: 5, + launched_at: null, + }; + + it('без campaignId, но с черновиком в списке — продолжает его (loadForEdit), createCampaign НЕ зовётся, isEditMode/подсказка НЕ включаются', async () => { + fetchCampaignsMock.mockResolvedValue([ + { id: 78, name: 'Крутится', status: 'running' as const, audience_days: 10, launched_at: '2026-07-01T00:00:00Z' }, + EXISTING_DRAFT, + ]); + fetchCampaignMock.mockResolvedValue({ campaign: EXISTING_DRAFT, ads: [], spent_rub: '0.00' }); + fetchAudienceSizeMock.mockResolvedValue({ size: 50, min: 100, enough: false, hint: 'мало' }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(fetchCampaignsMock).toHaveBeenCalledTimes(1); + expect(fetchCampaignMock).toHaveBeenCalledWith(77); + expect(createCampaignMock).not.toHaveBeenCalled(); + expect((w.vm as unknown as { draftId: number | null }).draftId).toBe(77); + + // Продолжение черновика — это НЕ режим правки (тот завязан на props.campaignId). + expect(w.find('[data-testid="wizard-edit-hint"]').exists()).toBe(false); + expect(w.text()).toContain('Новая реклама'); + expect(w.text()).not.toContain('Изменить кампанию'); + }); + + it('список кампаний упал (сбой сети) — не роняем мастер, фолбэк на createDraft', async () => { + fetchCampaignsMock.mockRejectedValue(new Error('network')); + createCampaignMock.mockResolvedValue({ + id: 88, + name: 'Реклама от 25.07.2026', + status: 'draft' as const, + audience_days: 10, + launched_at: null, + }); + fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(createCampaignMock).toHaveBeenCalledTimes(1); + expect((w.vm as unknown as { draftId: number | null }).draftId).toBe(88); + expect(w.find('[data-testid="wizard-draft-error"]').exists()).toBe(false); + }); +}); + +describe('CampaignWizard.vue (T18 — загрузка «моего списка номеров» на шаге 1)', () => { + beforeEach(() => { + createCampaignMock.mockReset(); + fetchAudienceSizeMock.mockReset(); + patchCampaignMock.mockReset(); + fetchCampaignsMock.mockReset(); + fetchCampaignsMock.mockResolvedValue([]); + uploadCampaignPhonesMock.mockReset(); + createCampaignMock.mockResolvedValue({ ...DRAFT_CAMPAIGN }); + fetchAudienceSizeMock.mockResolvedValue({ size: 120, min: 100, enough: true, hint: null }); + fetchBannersMock.mockReset(); + fetchBannersMock.mockResolvedValue(BANNERS_EMPTY); + }); + + it('блок загрузки скрыт, пока переключатель «мой список» выключен; появляется после включения', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="phones-upload-block"]').exists()).toBe(false); + + // Своего списка нет в auto — переключаемся в manual, чтобы переключатель появился (T18 сценарий). + (w.vm as unknown as { form: { mode: string } }).form.mode = 'manual'; + await w.vm.$nextTick(); + + const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input'); + await uploadSwitchInput.setValue(true); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="phones-upload-block"]').exists()).toBe(true); + }); + + it('кнопка «Загрузить список» зовёт uploadCampaignPhones(draftId, {file,text}) и показывает результат', async () => { + uploadCampaignPhonesMock.mockResolvedValue({ recognized: 8, skipped: 1 }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + // Своего списка нет в auto — переключаемся в manual, чтобы блок появился (T18 сценарий). + (w.vm as unknown as { form: { mode: string } }).form.mode = 'manual'; + await w.vm.$nextTick(); + + const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input'); + await uploadSwitchInput.setValue(true); + await w.vm.$nextTick(); + + const file = new File(['79261234567'], 'phones.txt', { type: 'text/plain' }); + (w.vm as unknown as { phonesFile: File | null }).phonesFile = file; + (w.vm as unknown as { phonesText: string }).phonesText = '79267654321'; + await w.vm.$nextTick(); + + await w.find('[data-testid="phones-upload-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(uploadCampaignPhonesMock).toHaveBeenCalledWith(99, { file, text: '79267654321' }); + const result = w.find('[data-testid="phones-upload-result"]'); + expect(result.exists()).toBe(true); + expect(result.text()).toContain('Распознано 8'); + expect(result.text()).toContain('отброшено 1'); + }); + + it('ошибка загрузки списка показывает понятный текст', async () => { + uploadCampaignPhonesMock.mockRejectedValue( + Object.assign(new Error('Не удалось разобрать файл.'), { + isAxiosError: true, + response: { status: 422, data: { message: 'Не удалось разобрать файл.' } }, + }), + ); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + // Своего списка нет в auto — переключаемся в manual, чтобы блок появился (T18 сценарий). + (w.vm as unknown as { form: { mode: string } }).form.mode = 'manual'; + await w.vm.$nextTick(); + + const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input'); + await uploadSwitchInput.setValue(true); + await w.vm.$nextTick(); + + // Кнопка теперь выключена при пустых файле и тексте (Ф3 «тихий ноль») — заполняем текст, + // чтобы дойти до реального запроса и проверить обработку его ошибки. + (w.vm as unknown as { phonesText: string }).phonesText = '79267654321'; + await w.vm.$nextTick(); + + await w.find('[data-testid="phones-upload-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const error = w.find('[data-testid="phones-upload-error"]'); + expect(error.exists()).toBe(true); + expect(error.text()).toContain('Не удалось разобрать файл.'); + }); + + it('кнопка «Загрузить список» выключена, пока и файл, и текст пустые', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + // Своего списка нет в auto — переключаемся в manual, чтобы блок появился (T18 сценарий). + (w.vm as unknown as { form: { mode: string } }).form.mode = 'manual'; + await w.vm.$nextTick(); + + const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input'); + await uploadSwitchInput.setValue(true); + await w.vm.$nextTick(); + + const btn = w.find('[data-testid="phones-upload-btn"]'); + expect(btn.attributes('disabled')).toBeDefined(); + + (w.vm as unknown as { phonesText: string }).phonesText = '79267654321'; + await w.vm.$nextTick(); + + expect(w.find('[data-testid="phones-upload-btn"]').attributes('disabled')).toBeUndefined(); + }); + + it('распознано 0 номеров — предупреждение, а не «успех»', async () => { + uploadCampaignPhonesMock.mockResolvedValue({ recognized: 0, skipped: 3 }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + // Своего списка нет в auto — переключаемся в manual, чтобы блок появился (T18 сценарий). + (w.vm as unknown as { form: { mode: string } }).form.mode = 'manual'; + await w.vm.$nextTick(); + + const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input'); + await uploadSwitchInput.setValue(true); + await w.vm.$nextTick(); + + (w.vm as unknown as { phonesText: string }).phonesText = 'не номер'; + await w.vm.$nextTick(); + + await w.find('[data-testid="phones-upload-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const result = w.find('[data-testid="phones-upload-result"]'); + expect(result.exists()).toBe(true); + expect(result.text()).toContain('Не распознан ни один номер'); + // v-alert type="warning" рендерит mdi-alert иконку по умолчанию, а не «успешный» чек. + expect(result.html()).not.toContain('mdi-check-circle'); + }); + + it('двойной клик по «Загрузить список», пока первый запрос ещё в полёте — вызывает uploadCampaignPhones РОВНО один раз', async () => { + let resolveUpload: ((v: { recognized: number; skipped: number }) => void) | null = null; + uploadCampaignPhonesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveUpload = resolve; + }), + ); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + // Своего списка нет в auto — переключаемся в manual, чтобы блок появился (T18 сценарий). + (w.vm as unknown as { form: { mode: string } }).form.mode = 'manual'; + await w.vm.$nextTick(); + + const uploadSwitchInput = w.find('[data-testid="use-list-switch"] input'); + await uploadSwitchInput.setValue(true); + await w.vm.$nextTick(); + + (w.vm as unknown as { phonesText: string }).phonesText = '79267654321'; + await w.vm.$nextTick(); + + const btn = w.find('[data-testid="phones-upload-btn"]'); + await btn.trigger('click'); + await btn.trigger('click'); + await flush(); + + resolveUpload?.({ recognized: 1, skipped: 0 }); + await flush(); + await w.vm.$nextTick(); + + expect(uploadCampaignPhonesMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/tests/Frontend/advertising-channels.spec.ts b/app/tests/Frontend/advertising-channels.spec.ts new file mode 100644 index 00000000..27995e4f --- /dev/null +++ b/app/tests/Frontend/advertising-channels.spec.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { AD_CHANNELS } from '../../resources/js/components/layout/advertisingChannels'; + +describe('AD_CHANNELS', () => { + it('yandex audience is a real route now, others remain stubs', () => { + const yandex = AD_CHANNELS.find((c) => c.testid === 'ad-nav-yandex-audience'); + expect(yandex?.route).toBe('/advertising/yandex'); + const stubs = AD_CHANNELS.filter((c) => c.testid !== 'ad-nav-yandex-audience'); + expect(stubs.every((c) => !c.route)).toBe(true); + }); +}); diff --git a/app/tests/Frontend/advertising-creative-form.spec.ts b/app/tests/Frontend/advertising-creative-form.spec.ts new file mode 100644 index 00000000..60fa3dc4 --- /dev/null +++ b/app/tests/Frontend/advertising-creative-form.spec.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { AxiosError } from 'axios'; + +/** + * Тест формы одного объявления (Часть B2, задача B2-6). + * + * Мокаем только addCreative/uploadCreativeImage (api/advertising) — client.ts + * (extractValidationErrors/extractErrorMessage) оставляем настоящим, чтобы + * проверить реальную обработку 422 от Laravel (Р35 — фронт не выдумывает + * нормативы, только показывает то, что вернул сервер). + */ +const { addCreativeMock, uploadCreativeImageMock } = vi.hoisted(() => ({ + addCreativeMock: vi.fn(), + uploadCreativeImageMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + addCreative: addCreativeMock, + uploadCreativeImage: uploadCreativeImageMock, +})); + +import CreativeForm from '../../resources/js/components/advertising/CreativeForm.vue'; +import type { AdCreative } from '../../resources/js/api/advertising'; + +const vuetify = createVuetify(); + +function axErr(status: number, body: unknown): AxiosError { + const e = new AxiosError('err'); + // @ts-expect-error — минимальный мок ответа + e.response = { status, data: body, statusText: '', headers: {}, config: {} }; + return e; +} + +const AD: AdCreative = { + id: 5, + campaign_id: 42, + title: 'Окна со скидкой', + text: 'Меняем окна за 3 дня, гарантия 5 лет', + href: 'https://example.ru', + title2: 'Успейте до конца месяца', + moderation_status: 'draft', + image_normal_hash: null, +}; + +async function fillForm(w: VueWrapper): Promise { + await w.find('[data-testid="creative-title"] input').setValue('Окна со скидкой'); + await w.find('[data-testid="creative-title2"] input').setValue('Успейте до конца месяца'); + await w.find('[data-testid="creative-text"] textarea').setValue('Меняем окна за 3 дня, гарантия 5 лет'); + await w.find('[data-testid="creative-href"] input').setValue('https://example.ru'); +} + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('CreativeForm.vue (B2-6 — форма одного объявления)', () => { + beforeEach(() => { + addCreativeMock.mockReset(); + uploadCreativeImageMock.mockReset(); + }); + + it('(a) заполненная форма шлёт addCreative и эмиттит added с ответом сервера', async () => { + addCreativeMock.mockResolvedValue(AD); + + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + await fillForm(w); + await w.find('[data-testid="creative-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(addCreativeMock).toHaveBeenCalledWith(42, { + title: 'Окна со скидкой', + text: 'Меняем окна за 3 дня, гарантия 5 лет', + href: 'https://example.ru', + title2: 'Успейте до конца месяца', + }); + expect(uploadCreativeImageMock).not.toHaveBeenCalled(); + expect(w.emitted('added')).toBeTruthy(); + expect(w.emitted('added')![0]).toEqual([AD]); + + // Поля очищены после успешного добавления. + expect((w.find('[data-testid="creative-title"] input').element as HTMLInputElement).value).toBe(''); + }); + + it('(b) сервер вернул 422 по title — сообщение показано под полем, added не эмиттится', async () => { + addCreativeMock.mockRejectedValue(axErr(422, { errors: { title: ['Слишком длинный заголовок'] } })); + + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + await fillForm(w); + await w.find('[data-testid="creative-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.text()).toContain('Слишком длинный заголовок'); + expect(w.emitted('added')).toBeFalsy(); + expect(w.find('[data-testid="creative-error"]').exists()).toBe(false); + }); + + it('(c) с выбранным файлом после addCreative вызывается uploadCreativeImage(campaignId, ad.id, file)', async () => { + addCreativeMock.mockResolvedValue(AD); + uploadCreativeImageMock.mockResolvedValue({ hash: 'abc123' }); + + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + await fillForm(w); + + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + // v-file-input в jsdom нельзя надёжно наполнить через DOM-событие смены файла — + // используем defineExpose (тот же приём, что и в CampaignWizard.vue). + (w.vm as unknown as { file: File | null }).file = file; + await w.vm.$nextTick(); + + await w.find('[data-testid="creative-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(addCreativeMock).toHaveBeenCalledTimes(1); + expect(uploadCreativeImageMock).toHaveBeenCalledWith(42, AD.id, file); + expect(w.emitted('added')).toBeTruthy(); + }); + + it('(d) выбор ОДНОГО файла через реальный change-event поля даёт одиночный File, не массив', async () => { + // T12 (двоение картинки, Ф1) — проверка гипотезы «v-file-input в 3.12.5 + // отдаёт File[] вместо одиночного File». jsdom не реализует DataTransfer, + // поэтому DOM `files` подменяем напрямую массивом — внутренний обработчик + // Vuetify (onFileSelection) делает [...(target.files ?? [])], простого + // массива достаточно, чтобы пройти тем же кодовым путём, что и в браузере. + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + const input = w.find('input[type="file"]').element as HTMLInputElement; + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + + await w.find('input[type="file"]').trigger('change'); + await w.vm.$nextTick(); + + const modelFile = (w.vm as unknown as { file: File | File[] | null }).file; + expect(modelFile).toBeInstanceOf(File); + expect(Array.isArray(modelFile)).toBe(false); + }); + + it('(e) двойной клик по кнопке сабмита, пока первый addCreative ещё в полёте — вызывает submit РОВНО один раз', async () => { + // T12 (двоение картинки, Ф1→Ф2) — КОРЕНЬ реального бага «картинка + // добавляется 2 вместо одной»: v-btn `:loading` НЕ ставит disabled + // (убирает только из tab-order), поэтому без guard'а быстрый двойной + // клик успевал вызвать submit() дважды до ответа первого addCreative — + // сервер создавал 2 объявления, и оба получали загруженную картинку + // (один и тот же file ещё лежал в модели). Фикс — guard `if (submitting.value) return` + // в начале submit() + `:disabled="submitting"` на кнопке. + const resolvers: Array<(ad: AdCreative) => void> = []; + let nextId = 5; + addCreativeMock.mockImplementation( + () => + new Promise((resolve) => { + const id = nextId++; + resolvers.push((ad) => resolve({ ...ad, id })); + }), + ); + uploadCreativeImageMock.mockResolvedValue({ hash: 'abc123' }); + + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + await fillForm(w); + + const file = new File(['x'], 'pic.png', { type: 'image/png' }); + (w.vm as unknown as { file: File | null }).file = file; + await w.vm.$nextTick(); + + const btn = w.find('[data-testid="creative-submit"]'); + await btn.trigger('click'); + await btn.trigger('click'); + await flush(); + + resolvers.forEach((r) => r(AD)); + await flush(); + await w.vm.$nextTick(); + + expect(addCreativeMock).toHaveBeenCalledTimes(1); + expect(uploadCreativeImageMock).toHaveBeenCalledTimes(1); + }); + + it('(f) БЛОКЕР — сервер вернул fieldErrors в «плоском» формате (строка на поле, не массив) — не падает, показывает текст', async () => { + // B1 CreativeValidator (Api/AdvertisingCampaignController::storeAd) отдаёт + // errors как array — одна строка на поле title/title2/text/image, + // а НЕ Laravel-массив сообщений (как для href, обычная `$request->validate`). + // Раньше `messages.map(humanizeMessage)` падал TypeError'ом на такой форме. + addCreativeMock.mockRejectedValue( + axErr(422, { errors: { title: 'Заголовок длиннее 56 символов' } }), + ); + + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + await fillForm(w); + await w.find('[data-testid="creative-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(w.text()).toContain('Заголовок длиннее 56 символов'); + expect(w.emitted('added')).toBeFalsy(); + }); + + it('общая ошибка (не 422) показывается в v-alert, а не под полем', async () => { + addCreativeMock.mockRejectedValue(axErr(500, { message: 'Внутренняя ошибка сервера.' })); + + const w = mount(CreativeForm, { props: { campaignId: 42 }, global: { plugins: [vuetify] } }); + await fillForm(w); + await w.find('[data-testid="creative-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const alert = w.find('[data-testid="creative-error"]'); + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain('Внутренняя ошибка сервера.'); + expect(w.emitted('added')).toBeFalsy(); + }); +}); diff --git a/app/tests/Frontend/advertising-wallet-header.spec.ts b/app/tests/Frontend/advertising-wallet-header.spec.ts new file mode 100644 index 00000000..24b75a1d --- /dev/null +++ b/app/tests/Frontend/advertising-wallet-header.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +const { pushMock, fetchWalletMock } = vi.hoisted(() => ({ + pushMock: vi.fn(), + fetchWalletMock: vi.fn(), +})); + +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: pushMock })), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchWallet: fetchWalletMock, +})); + +import AdWalletHeader from '../../resources/js/components/advertising/AdWalletHeader.vue'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('AdWalletHeader.vue (B2-3 — шапка-кошелёк)', () => { + beforeEach(() => { + pushMock.mockClear(); + fetchWalletMock.mockReset(); + }); + + it('показывает баланс/заморожено/свободно и НЕ показывает алерт, когда solvent=true', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: true, + balance_rub: '18400.00', + frozen_rub: '2500.00', + free_rub: '15900.00', + }); + const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + // Intl.NumberFormat('ru-RU') разделяет тысячи неразрывным пробелом (U+00A0) — матчим любым \s. + expect(w.find('[data-testid="wallet-balance"]').text()).toMatch(/18\s400/); + expect(w.find('[data-testid="wallet-frozen"]').text()).toMatch(/2\s500/); + expect(w.find('[data-testid="wallet-free"]').text()).toMatch(/15\s900/); + expect(w.find('[data-testid="wallet-insolvent-alert"]').exists()).toBe(false); + }); + + it('показывает заметный алерт «Пополните рекламный кошелёк», когда solvent=false', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: false, + balance_rub: '0.00', + frozen_rub: '0.00', + free_rub: '0.00', + }); + const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + const alert = w.find('[data-testid="wallet-insolvent-alert"]'); + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain('Пополните рекламный кошелёк'); + expect(alert.text()).toContain('реклама остановлена'); + }); + + it('кнопка «Пополнить рекламный кошелёк» открывает диалог пополнения (НЕ router.push)', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: true, + balance_rub: '18400.00', + frozen_rub: '2500.00', + free_rub: '15900.00', + }); + const w = mount(AdWalletHeader, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.findComponent({ name: 'AdWalletTopupDialog' }).exists()).toBe(true); + expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(false); + + await w.find('[data-testid="wallet-topup-btn"]').trigger('click'); + await w.vm.$nextTick(); + + expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(true); + expect(pushMock).not.toHaveBeenCalledWith('/billing'); + }); + + it('ошибка загрузки не рушит экран — тихий inline-текст вместо сумм', async () => { + fetchWalletMock.mockRejectedValue(new Error('network fail')); + const w = mount(AdWalletHeader, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="wallet-balance"]').exists()).toBe(false); + expect(w.find('[data-testid="wallet-insolvent-alert"]').exists()).toBe(false); + expect(w.find('[data-testid="wallet-load-error"]').exists()).toBe(true); + }); +}); diff --git a/app/tests/Frontend/advertising-wallet-topup.spec.ts b/app/tests/Frontend/advertising-wallet-topup.spec.ts new file mode 100644 index 00000000..2a0bb201 --- /dev/null +++ b/app/tests/Frontend/advertising-wallet-topup.spec.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * AdWalletTopupDialog (Рекламный кошелёк, Часть A, T3 + C2) — диалог пополнения + * РЕКЛАМНОГО кошелька: два способа — + * • «Оплатить картой» (POST /api/billing/topup, credit_target='advertising' — редирект + * на confirmation_url при включённом шлюзе ЮKassa, либо мгновенный успех {ok:true} + * при заглушке); + * • «Получить счёт» (POST /api/billing/invoices, credit_target='advertising' — по + * реквизитам для юрлиц, регресс из T3, не должен сломаться). + */ +const { createAdvertisingInvoiceMock, topupAdvertisingByCardMock } = vi.hoisted(() => ({ + createAdvertisingInvoiceMock: vi.fn(), + topupAdvertisingByCardMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + createAdvertisingInvoice: createAdvertisingInvoiceMock, + topupAdvertisingByCard: topupAdvertisingByCardMock, +})); + +const { redirectToMock } = vi.hoisted(() => ({ redirectToMock: vi.fn() })); + +vi.mock('../../resources/js/utils/redirect', () => ({ + redirectTo: redirectToMock, +})); + +import AdWalletTopupDialog from '../../resources/js/components/advertising/AdWalletTopupDialog.vue'; + +const vuetify = createVuetify(); + +// v-dialog требует layout-injection — stub'им как passthrough div (тот же приём, +// что и в advertising-campaign-report.spec.ts / CampaignReportDialog.vue). +const DIALOG_STUBS = { + VDialog: { + template: '
', + props: ['modelValue'], + }, +}; + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('AdWalletTopupDialog.vue (T3 + C2 — пополнение рекламного кошелька)', () => { + beforeEach(() => { + createAdvertisingInvoiceMock.mockReset(); + topupAdvertisingByCardMock.mockReset(); + redirectToMock.mockReset(); + }); + + it('предлагает оба способа оплаты — картой и по счёту, без устаревшего текста про Б-1', () => { + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + expect(w.text()).toContain('Оплатить картой'); + expect(w.text()).toContain('Получить счёт'); + expect(w.text()).toContain('моментально'); + expect(w.text()).not.toContain('скоро появится'); + }); + + it('«Оплатить картой» при confirmation_url в ответе делает редирект на оплату', async () => { + topupAdvertisingByCardMock.mockResolvedValue({ confirmation_url: 'https://yoomoney.ru/checkout/pay_x' }); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(5000); + await w.find('[data-testid="topup-card-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(topupAdvertisingByCardMock).toHaveBeenCalledWith(5000); + expect(redirectToMock).toHaveBeenCalledWith('https://yoomoney.ru/checkout/pay_x'); + }); + + it('«Оплатить картой» при {ok:true} (заглушка) показывает успех и эмитит topped-up', async () => { + topupAdvertisingByCardMock.mockResolvedValue({ ok: true }); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(3000); + await w.find('[data-testid="topup-card-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(redirectToMock).not.toHaveBeenCalled(); + expect(w.text()).toContain('Кошелёк пополнен'); + expect(w.emitted('topped-up')).toBeTruthy(); + }); + + it('ошибка оплаты картой показывает читаемое сообщение в topup-error', async () => { + topupAdvertisingByCardMock.mockRejectedValue(new Error('network fail')); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(500); + await w.find('[data-testid="topup-card-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const error = w.find('[data-testid="topup-error"]'); + expect(error.exists()).toBe(true); + expect(error.text()).toContain('Произошла ошибка. Попробуйте позже.'); + }); + + it('«Получить счёт» по-прежнему создаёт счёт с credit_target=advertising и показывает PDF (регресс T3)', async () => { + createAdvertisingInvoiceMock.mockResolvedValue({ + id: 1, + invoice_number: 'СЧ-2026-0001', + amount_total: '5000.00', + pdf_url: '/api/billing/invoices/1/pdf', + }); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(5000); + await w.find('[data-testid="topup-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(createAdvertisingInvoiceMock).toHaveBeenCalledWith(5000); + expect(topupAdvertisingByCardMock).not.toHaveBeenCalled(); + + expect(w.text()).toContain('СЧ-2026-0001'); + const link = w.find('[data-testid="topup-pdf-link"]'); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('/api/billing/invoices/1/pdf'); + }); + + it('ошибка создания счёта показывает читаемое сообщение в topup-error', async () => { + createAdvertisingInvoiceMock.mockRejectedValue(new Error('network fail')); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(500); + await w.find('[data-testid="topup-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const error = w.find('[data-testid="topup-error"]'); + expect(error.exists()).toBe(true); + expect(error.text()).toContain('Произошла ошибка. Попробуйте позже.'); + expect(w.find('[data-testid="topup-pdf-link"]').exists()).toBe(false); + }); + + it('T20: клик по пресету 3000 подставляет сумму в поле', async () => { + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + expect((w.find('[data-testid="topup-amount"] input').element as HTMLInputElement).value).toBe(''); + + await w.find('[data-testid="topup-preset-3000"]').trigger('click'); + await w.vm.$nextTick(); + + expect((w.find('[data-testid="topup-amount"] input').element as HTMLInputElement).value).toBe('3000'); + }); + + it('T20: при пустой сумме «Оплатить картой» выключена', () => { + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + const btn = w.find('[data-testid="topup-card-btn"]'); + expect(btn.attributes('disabled')).not.toBeUndefined(); + }); + + it('T20: после подстановки пресета «Оплатить картой» становится активной', async () => { + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-preset-1000"]').trigger('click'); + await w.vm.$nextTick(); + + const btn = w.find('[data-testid="topup-card-btn"]'); + expect(btn.attributes('disabled')).toBeUndefined(); + }); + + it('T21: ни confirmation_url, ни ok:true в ответе — явная ошибка, не тихий тупик', async () => { + topupAdvertisingByCardMock.mockResolvedValue({}); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(2000); + await w.find('[data-testid="topup-card-btn"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + expect(redirectToMock).not.toHaveBeenCalled(); + expect(w.emitted('topped-up')).toBeFalsy(); + const error = w.find('[data-testid="topup-error"]'); + expect(error.exists()).toBe(true); + expect(error.text().length).toBeGreaterThan(0); + }); + + it('открытие диалога сбрасывает предыдущую сумму/ошибку/счёт/успех оплаты картой', async () => { + createAdvertisingInvoiceMock.mockResolvedValue({ + id: 1, + invoice_number: 'СЧ-2026-0002', + amount_total: '1000.00', + pdf_url: '/api/billing/invoices/1/pdf', + }); + + const w = mount(AdWalletTopupDialog, { + props: { modelValue: true }, + global: { plugins: [vuetify], stubs: DIALOG_STUBS }, + }); + + await w.find('[data-testid="topup-amount"] input').setValue(1000); + await w.find('[data-testid="topup-submit"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + expect(w.text()).toContain('СЧ-2026-0002'); + + await w.setProps({ modelValue: false }); + await w.setProps({ modelValue: true }); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="topup-pdf-link"]').exists()).toBe(false); + expect(w.text()).not.toContain('СЧ-2026-0002'); + }); +}); diff --git a/app/tests/Frontend/advertising-wallet-view.spec.ts b/app/tests/Frontend/advertising-wallet-view.spec.ts new file mode 100644 index 00000000..dd89a05f --- /dev/null +++ b/app/tests/Frontend/advertising-wallet-view.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * AdWalletView (T19 — страница «Рекламный кошелёк», план 2026-07-25). + * Баланс через fetchWallet() (тот же источник, что и AdWalletHeader), кнопка + * «Пополнить» открывает AdWalletTopupDialog, история операций — пометка + * «появится позже» (клиентского эндпоинта списка операций нет). + */ +const { fetchWalletMock } = vi.hoisted(() => ({ + fetchWalletMock: vi.fn(), +})); + +vi.mock('../../resources/js/api/advertising', () => ({ + fetchWallet: fetchWalletMock, +})); + +import AdWalletView from '../../resources/js/views/advertising/AdWalletView.vue'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('AdWalletView.vue (T19 — страница «Рекламный кошелёк»)', () => { + beforeEach(() => { + fetchWalletMock.mockReset(); + }); + + it('показывает баланс/заморожено/свободно из fetchWallet', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: true, + balance_rub: '18400.00', + frozen_rub: '2500.00', + free_rub: '15900.00', + }); + const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="ad-wallet-balance"]').text()).toMatch(/18\s400/); + expect(w.find('[data-testid="ad-wallet-frozen"]').text()).toMatch(/2\s500/); + expect(w.find('[data-testid="ad-wallet-free"]').text()).toMatch(/15\s900/); + }); + + it('показывает заметный алерт, когда solvent=false', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: false, + balance_rub: '0.00', + frozen_rub: '0.00', + free_rub: '0.00', + }); + const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="ad-wallet-insolvent-alert"]').exists()).toBe(true); + }); + + it('кнопка «Пополнить» открывает AdWalletTopupDialog', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: true, + balance_rub: '1000.00', + frozen_rub: '0.00', + free_rub: '1000.00', + }); + const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(false); + + await w.find('[data-testid="ad-wallet-topup-btn"]').trigger('click'); + await w.vm.$nextTick(); + + expect(w.findComponent({ name: 'AdWalletTopupDialog' }).props('modelValue')).toBe(true); + }); + + it('история операций помечена «появится позже» — клиентского эндпоинта списка операций нет', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: true, + balance_rub: '1000.00', + frozen_rub: '0.00', + free_rub: '1000.00', + }); + const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="ad-wallet-history-placeholder"]').text()).toContain('появится позже'); + }); + + it('ошибка загрузки не рушит экран — тихий inline-текст вместо сумм', async () => { + fetchWalletMock.mockRejectedValue(new Error('network fail')); + const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="ad-wallet-balance"]').exists()).toBe(false); + expect(w.find('[data-testid="ad-wallet-load-error"]').exists()).toBe(true); + }); + + it('после успешного пополнения (topped-up) баланс перечитывается', async () => { + fetchWalletMock.mockResolvedValue({ + solvent: true, + balance_rub: '1000.00', + frozen_rub: '0.00', + free_rub: '1000.00', + }); + const w = mount(AdWalletView, { global: { plugins: [vuetify], stubs: { teleport: true } } }); + await flush(); + await w.vm.$nextTick(); + expect(fetchWalletMock).toHaveBeenCalledTimes(1); + + await w.findComponent({ name: 'AdWalletTopupDialog' }).vm.$emit('topped-up'); + await flush(); + await w.vm.$nextTick(); + + expect(fetchWalletMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/tests/Frontend/advertising-yandex-view.spec.ts b/app/tests/Frontend/advertising-yandex-view.spec.ts new file mode 100644 index 00000000..7dee3124 --- /dev/null +++ b/app/tests/Frontend/advertising-yandex-view.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +// AdvertisingYandexView теперь рендерит AdWalletHeader (B2-3), который дёргает +// fetchWallet() и useRouter() при монтировании — мокаем оба, чтобы не ходить в сеть. +vi.mock('vue-router', () => ({ + useRouter: vi.fn(() => ({ push: vi.fn() })), +})); + +const { fetchCampaignsMock, fetchCampaignMock } = vi.hoisted(() => ({ + fetchCampaignsMock: vi.fn(() => Promise.resolve([])), + // B2-9 — режим правки: CampaignWizard зовёт fetchCampaign(id), когда контейнер + // передаёт ему campaignId (после «Изменить» на карточке). + fetchCampaignMock: vi.fn(() => + Promise.resolve({ + campaign: { + id: 7, + name: 'Реклама от 01.07.2026', + status: 'running', + weekly_budget_rub: '2000.00', + audience_days: 15, + launched_at: '2026-07-01T10:00:00Z', + use_uploaded_list: false, + daily_budget_rub: null, + }, + ads: [], + spent_rub: '0.00', + }), + ), +})); + +// CampaignWizard (B2-5) теперь рендерится во вкладке «Новая реклама» и на +// монтировании создаёт черновик кампании + считает счётчик аудитории — +// мокаем и то, и другое, чтобы не ходить в сеть. +vi.mock('../../resources/js/api/advertising', () => ({ + fetchWallet: vi.fn(() => + Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }), + ), + fetchCampaigns: fetchCampaignsMock, + fetchCampaign: fetchCampaignMock, + createCampaign: vi.fn(() => + Promise.resolve({ + id: 99, + name: 'Реклама от 25.07.2026', + status: 'draft', + weekly_budget_rub: '1000.00', + audience_days: 10, + launched_at: null, + }), + ), + fetchAudienceSize: vi.fn(() => Promise.resolve({ size: 120, min: 100, enough: true, hint: null })), + launchCampaign: vi.fn(() => Promise.resolve({ status: 'pending_moderation' })), +})); + +import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue'; + +const vuetify = createVuetify(); + +async function flush(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +describe('AdvertisingYandexView.vue', () => { + const factory = () => + mount(AdvertisingYandexView, { + global: { plugins: [vuetify] }, + }); + + it('рендерит корень экрана', () => { + const w = factory(); + expect(w.find('[data-testid="advertising-yandex-view"]').exists()).toBe(true); + }); + + it('показывает обе вкладки', () => { + const w = factory(); + const text = w.text(); + expect(text).toContain('Мои кампании'); + expect(text).toContain('Новая реклама'); + }); + + it('(d) на событие launched от мастера переключает вкладку на «Мои кампании» и перезагружает список', async () => { + fetchCampaignsMock.mockClear(); + const w = factory(); + await flush(); + await w.vm.$nextTick(); + + const callsBeforeSwitch = fetchCampaignsMock.mock.calls.length; + + // Переходим во вкладку «Новая реклама» — там же примонтируется мастер (v-tabs-window ленивый). + await w.find('[data-testid="tab-new"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const wizard = w.findComponent({ name: 'CampaignWizard' }); + expect(wizard.exists()).toBe(true); + wizard.vm.$emit('launched'); + await flush(); + await w.vm.$nextTick(); + + expect((w.vm as unknown as { tab: string }).tab).toBe('campaigns'); + expect(fetchCampaignsMock.mock.calls.length).toBeGreaterThan(callsBeforeSwitch); + }); + + it('(B2-9) «Изменить» на карточке (edit от CampaignList) переключает на мастер в режиме правки с этим campaignId', async () => { + fetchCampaignMock.mockClear(); + const w = factory(); + await flush(); + await w.vm.$nextTick(); + + const list = w.findComponent({ name: 'CampaignList' }); + expect(list.exists()).toBe(true); + list.vm.$emit('edit', 7); + await flush(); + await w.vm.$nextTick(); + + expect((w.vm as unknown as { tab: string }).tab).toBe('new'); + + const wizard = w.findComponent({ name: 'CampaignWizard' }); + expect(wizard.exists()).toBe(true); + expect(wizard.props('campaignId')).toBe(7); + expect(fetchCampaignMock).toHaveBeenCalledWith(7); + }); + + it('(B2-9) после запуска правки контейнер сбрасывает campaignId — новый заход в мастер снова чистый (createCampaign)', async () => { + fetchCampaignMock.mockClear(); + const w = factory(); + await flush(); + await w.vm.$nextTick(); + + const list = w.findComponent({ name: 'CampaignList' }); + list.vm.$emit('edit', 7); + await flush(); + await w.vm.$nextTick(); + + let wizard = w.findComponent({ name: 'CampaignWizard' }); + expect(wizard.props('campaignId')).toBe(7); + + wizard.vm.$emit('launched'); + await flush(); + await w.vm.$nextTick(); + + expect((w.vm as unknown as { tab: string }).tab).toBe('campaigns'); + + // Открываем «Новая реклама» заново (клик по вкладке, БЕЗ edit) — должен быть чистый мастер. + await w.find('[data-testid="tab-new"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + wizard = w.findComponent({ name: 'CampaignWizard' }); + expect(wizard.exists()).toBe(true); + expect(wizard.props('campaignId')).toBeFalsy(); + }); +}); diff --git a/app/tests/Frontend/creativeLimits.spec.ts b/app/tests/Frontend/creativeLimits.spec.ts new file mode 100644 index 00000000..f17dbd53 --- /dev/null +++ b/app/tests/Frontend/creativeLimits.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { + validateTitle, + validateTitle2, + validateText, + NARROW_CHARS, + countNormalAndNarrow, +} from '../../resources/js/lib/advertising/creativeLimits'; + +/** Строка заданной общей длины из слов не длиннее wordLen (для тестов общего лимита длины). */ +function buildLine(totalLen: number, wordLen: number): string { + let result = ''; + while (result.length < totalLen) { + if (result.length > 0) { + result += ' '; + } + const remaining = totalLen - result.length; + const w = Math.min(wordLen, remaining); + result += 'а'.repeat(w); + } + return result; +} + +describe('creativeLimits — validateTitle', () => { + it('пропускает заголовок ровно 56 символов (слова не длиннее 22)', () => { + const title = buildLine(56, 20); + expect(validateTitle(title)).toBeNull(); + }); + + it('отклоняет заголовок длиннее 56 символов', () => { + const title = buildLine(57, 20); + expect(validateTitle(title)).toBe('Заголовок длиннее 56 символов'); + }); + + it('пропускает слово ровно 22 символа', () => { + const title = 'а'.repeat(22); + expect(validateTitle(title)).toBeNull(); + }); + + it('отклоняет слово длиннее 22 символов', () => { + const title = 'а'.repeat(23); + expect(validateTitle(title)).toBe('В заголовке есть слово длиннее 22 символов'); + }); +}); + +describe('creativeLimits — validateTitle2', () => { + it('пропускает 30 обычных символов', () => { + const title2 = 'а'.repeat(30); + expect(validateTitle2(title2)).toBeNull(); + }); + + it('отклоняет 31 обычный символ', () => { + const title2 = 'а'.repeat(31); + expect(validateTitle2(title2)).toBe( + 'Второй заголовок длиннее нормы (30 обычных символов + до 15 узких)' + ); + }); + + it('обычные и узкие считаются раздельно: 30 обычных + 5 узких — ок', () => { + const title2 = 'а'.repeat(30) + '!'.repeat(5); + expect(validateTitle2(title2)).toBeNull(); + }); + + it('отклоняет, если узких больше 15 даже при малом числе обычных', () => { + const title2 = 'а'.repeat(5) + '!'.repeat(16); + expect(validateTitle2(title2)).toBe( + 'Второй заголовок длиннее нормы (30 обычных символов + до 15 узких)' + ); + }); +}); + +describe('creativeLimits — validateText', () => { + it('пропускает 81 обычный символ (слова не длиннее 23)', () => { + const text = buildLine(81, 20); + expect(validateText(text)).toBeNull(); + }); + + it('отклоняет 82 обычных символа', () => { + const text = buildLine(82, 20); + expect(validateText(text)).toBe( + 'Текст длиннее нормы (81 обычных символов + до 15 узких)' + ); + }); + + it('пропускает слово ровно 23 символа', () => { + const text = 'а'.repeat(23); + expect(validateText(text)).toBeNull(); + }); + + it('отклоняет слово длиннее 23 символов', () => { + const text = 'а'.repeat(24); + expect(validateText(text)).toBe('В тексте есть слово длиннее 23 символов'); + }); +}); + +describe('creativeLimits — NARROW_CHARS и countNormalAndNarrow', () => { + it('содержит все узкие символы норматива', () => { + expect(NARROW_CHARS).toEqual(['!', ',', '.', ';', ':', '"']); + }); + + it('считает обычные и узкие раздельно', () => { + expect(countNormalAndNarrow('ab!c,')).toEqual({ normal: 3, narrow: 2 }); + }); +}); diff --git a/app/tests/Frontend/humanizeCreativeErrors.spec.ts b/app/tests/Frontend/humanizeCreativeErrors.spec.ts new file mode 100644 index 00000000..f9b556aa --- /dev/null +++ b/app/tests/Frontend/humanizeCreativeErrors.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { humanizeMessage } from '../../resources/js/lib/advertising/humanizeCreativeErrors'; + +/** + * humanizeMessage — заменяет технические имена полей (title/title2/text/href/image) + * в сообщениях 422 от сервера (B1 CreativeValidator) на русские подписи полей формы. + */ +describe('humanizeCreativeErrors — humanizeMessage', () => { + it('заменяет "в поле title" на "в поле «Заголовок»"', () => { + const result = humanizeMessage('Количество символов в поле title не может превышать 56.'); + expect(result).not.toMatch(/\btitle\b/); + expect(result).toContain('«Заголовок»'); + expect(result).toBe('Количество символов в поле «Заголовок» не может превышать 56.'); + }); + + it('заменяет "Поле text" на "Поле «Текст объявления»"', () => { + const result = humanizeMessage('Поле text обязательно для заполнения.'); + expect(result).not.toMatch(/\btext\b/); + expect(result).toContain('«Текст объявления»'); + expect(result).toBe('Поле «Текст объявления» обязательно для заполнения.'); + }); + + it('заменяет "Поле href" на "Поле «Ссылка на сайт»"', () => { + const result = humanizeMessage('Поле href должно содержать корректный URL.'); + expect(result).not.toMatch(/\bhref\b/); + expect(result).toContain('«Ссылка на сайт»'); + expect(result).toBe('Поле «Ссылка на сайт» должно содержать корректный URL.'); + }); + + it('заменяет "в поле title2" на "в поле «Второй заголовок»"', () => { + const result = humanizeMessage('Значение в поле title2 некорректно.'); + expect(result).not.toMatch(/\btitle2\b/); + expect(result).toContain('«Второй заголовок»'); + }); + + it('заменяет "Поле image" на "Поле «Картинка»"', () => { + const result = humanizeMessage('Поле image обязательно для заполнения.'); + expect(result).not.toMatch(/\bimage\b/); + expect(result).toContain('«Картинка»'); + }); + + it('не трогает сообщение без известного технического имени поля', () => { + const result = humanizeMessage('Внутренняя ошибка сервера.'); + expect(result).toBe('Внутренняя ошибка сервера.'); + }); +}); diff --git a/app/tests/Frontend/imageRule.spec.ts b/app/tests/Frontend/imageRule.spec.ts new file mode 100644 index 00000000..32b31de5 --- /dev/null +++ b/app/tests/Frontend/imageRule.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { checkImage } from '../../resources/js/lib/advertising/imageRule'; + +describe('imageRule — checkImage', () => { + it('пропускает 500×500 png весом 1 КБ', () => { + const result = checkImage({ width: 500, height: 500, bytes: 1024, mime: 'image/png' }); + expect(result).toBeNull(); + }); + + it('отклоняет картинку меньше минимума 450×450', () => { + const result = checkImage({ width: 449, height: 500, bytes: 1024, mime: 'image/png' }); + expect(result).toMatch(/450/); + }); + + it('отклоняет картинку тяжелее 10 МБ', () => { + const result = checkImage({ + width: 500, + height: 500, + bytes: 11 * 1024 * 1024, + mime: 'image/png', + }); + expect(result).toMatch(/10/); + }); + + it('отклоняет недопустимый формат (webp)', () => { + const result = checkImage({ width: 500, height: 500, bytes: 1024, mime: 'image/webp' }); + expect(result).toMatch(/формат/i); + }); +}); diff --git a/app/tests/Frontend/urlRule.spec.ts b/app/tests/Frontend/urlRule.spec.ts new file mode 100644 index 00000000..75d8bd56 --- /dev/null +++ b/app/tests/Frontend/urlRule.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { isValidHttpUrl } from '../../resources/js/lib/advertising/urlRule'; + +describe('urlRule — isValidHttpUrl', () => { + it('принимает https-ссылку', () => { + expect(isValidHttpUrl('https://liderra.ru')).toBe(true); + }); + + it('принимает http-ссылку', () => { + expect(isValidHttpUrl('http://a.b')).toBe(true); + }); + + it('отклоняет строку, не являющуюся ссылкой', () => { + expect(isValidHttpUrl('это_не_ссылка')).toBe(false); + }); + + it('отклоняет ftp-ссылку', () => { + expect(isValidHttpUrl('ftp://x')).toBe(false); + }); + + it('отклоняет пустую строку', () => { + expect(isValidHttpUrl('')).toBe(false); + }); +}); diff --git a/app/tests/Unit/Advertising/AdImpressionPricingTest.php b/app/tests/Unit/Advertising/AdImpressionPricingTest.php new file mode 100644 index 00000000..6e647ff1 --- /dev/null +++ b/app/tests/Unit/Advertising/AdImpressionPricingTest.php @@ -0,0 +1,31 @@ +impressionsForAudience(1630, 15))->toBe(24450); + expect($p->impressionsForAudience(0, 15))->toBe(0); + expect($p->impressionsForAudience(100, 1))->toBe(100); +}); + +it('считает клиентскую сумму по 120 ₽ за 1000 показов, округляя ВВЕРХ до копейки', function () { + $p = new AdImpressionPricing; + expect($p->clientCostRub(24450, '120.00'))->toBe('2934.00'); + expect($p->clientCostRub(1, '120.00'))->toBe('0.12'); + expect($p->clientCostRub(7, '120.00'))->toBe('0.84'); + expect($p->clientCostRub(0, '120.00'))->toBe('0.00'); +}); + +it('округляет неровную сумму вверх до копейки (клиент не недоплачивает)', function () { + $p = new AdImpressionPricing; + expect($p->clientCostRub(1, '125.00'))->toBe('0.13'); +}); + +it('считает маржу как списано клиенту минус расход Яндекса', function () { + $p = new AdImpressionPricing; + expect($p->marginRub('2934.00', '1956.00'))->toBe('978.00'); + expect($p->marginRub('100.00', '100.00'))->toBe('0.00'); +}); diff --git a/app/tests/Unit/Advertising/BannerGeneratorTest.php b/app/tests/Unit/Advertising/BannerGeneratorTest.php new file mode 100644 index 00000000..c76e5426 --- /dev/null +++ b/app/tests/Unit/Advertising/BannerGeneratorTest.php @@ -0,0 +1,72 @@ +coverJpeg(srcJpeg(2000, 2000), 300, 250); + + $info = getimagesizefromstring($out); + expect($info)->not->toBeFalse() + ->and($info[0])->toBe(300) + ->and($info[1])->toBe(250) + ->and($info['mime'])->toBe('image/jpeg') + ->and(strlen($out))->toBeLessThanOrEqual(512000); +}); + +it('работает для широкого и высокого форматов (cover, точные пиксели)', function () { + $gen = new BannerGenerator; + + $wide = getimagesizefromstring($gen->coverJpeg(srcJpeg(1200, 1200), 728, 90)); + expect($wide[0])->toBe(728)->and($wide[1])->toBe(90); + + $tall = getimagesizefromstring($gen->coverJpeg(srcJpeg(1200, 1200), 240, 600)); + expect($tall[0])->toBe(240)->and($tall[1])->toBe(600); +}); + +/** Хелпер: «шумная» картинка — такую JPEG не ужимает почти никак, вес остаётся большим. */ +function noisyJpeg(int $w, int $h): string +{ + $img = imagecreatetruecolor($w, $h); + for ($x = 0; $x < $w; $x += 2) { + for ($y = 0; $y < $h; $y += 2) { + imagesetpixel($img, $x, $y, imagecolorallocate($img, ($x * 7) % 255, ($y * 13) % 255, ($x * $y) % 255)); + } + } + ob_start(); + imagejpeg($img, null, 95); + $bin = (string) ob_get_clean(); + imagedestroy($img); + + return $bin; +} + +/** + * Подбор качества упирается в пол 40 и дальше жать не может. Раньше в этом случае наружу + * молча уходил файл любого веса — а кабинет Яндекса тяжелее предела его не примет, и узнали + * бы мы об этом уже роботом, стоящим перед окном загрузки на боевом. Лучше честно сказать, + * что картинка не ужимается, чем отдать заведомо негодный файл. + */ +it('не отдаёт молча файл тяжелее предела, а честно отказывается', function () { + expect(fn () => (new BannerGenerator)->coverJpeg(noisyJpeg(1200, 1200), 970, 250, 2000)) + ->toThrow(RuntimeException::class); +}); + +it('бросает исключение на нечитаемой картинке', function () { + expect(fn () => (new BannerGenerator)->coverJpeg('не картинка', 300, 250)) + ->toThrow(RuntimeException::class); +}); diff --git a/app/tests/Unit/Advertising/BannerSizesTest.php b/app/tests/Unit/Advertising/BannerSizesTest.php new file mode 100644 index 00000000..da4fbcc6 --- /dev/null +++ b/app/tests/Unit/Advertising/BannerSizesTest.php @@ -0,0 +1,24 @@ +toHaveCount(15); + foreach ($sizes as $s) { + expect($s)->toHaveCount(2) + ->and($s[0])->toBeInt()->toBeGreaterThan(0) + ->and($s[1])->toBeInt()->toBeGreaterThan(0); + } +}); + +it('содержит ходовые размеры (300×250, 240×400, 728×90, 970×250)', function () { + $sizes = BannerSizes::all(); + expect($sizes)->toContain([300, 250]) + ->toContain([240, 400]) + ->toContain([728, 90]) + ->toContain([970, 250]); +}); diff --git a/app/tests/Unit/Advertising/BannerUploadPolicyTest.php b/app/tests/Unit/Advertising/BannerUploadPolicyTest.php new file mode 100644 index 00000000..874f6481 --- /dev/null +++ b/app/tests/Unit/Advertising/BannerUploadPolicyTest.php @@ -0,0 +1,17 @@ +toBe(512000); +}); + +it('formats the limit for humans in kilobytes', function () { + expect(BannerUploadPolicy::humanMaxSize())->toBe('500 КБ'); +}); + +it('accepts the image formats yandex takes', function () { + expect(BannerUploadPolicy::FORMATS)->toBe(['jpg', 'jpeg', 'png', 'gif']); +}); diff --git a/app/tests/Unit/Advertising/CreativeIdMatcherTest.php b/app/tests/Unit/Advertising/CreativeIdMatcherTest.php new file mode 100644 index 00000000..7de9b2f9 --- /dev/null +++ b/app/tests/Unit/Advertising/CreativeIdMatcherTest.php @@ -0,0 +1,40 @@ + [300, 250]]; + $after = [111 => [300, 250], 222 => [300, 250], 333 => [728, 90]]; + + $matched = (new CreativeIdMatcher)->match($before, $after, [[300, 250], [728, 90]]); + + expect($matched)->toBe(['300x250' => 222, '728x90' => 333]); +}); + +it('fails when a expected size did not appear', function () { + $before = []; + $after = [222 => [300, 250]]; + + expect(fn () => (new CreativeIdMatcher)->match($before, $after, [[300, 250], [728, 90]])) + ->toThrow(CreativeMatchFailedException::class, '728x90'); +}); + +it('fails when two new creatives share one size', function () { + $before = []; + $after = [222 => [300, 250], 223 => [300, 250]]; + + expect(fn () => (new CreativeIdMatcher)->match($before, $after, [[300, 250]])) + ->toThrow(CreativeMatchFailedException::class, '300x250'); +}); + +it('ignores new creatives of sizes we did not ask for', function () { + $before = []; + $after = [222 => [300, 250], 999 => [160, 600]]; + + $matched = (new CreativeIdMatcher)->match($before, $after, [[300, 250]]); + + expect($matched)->toBe(['300x250' => 222]); +}); diff --git a/app/tests/Unit/Advertising/CreativeValidatorTest.php b/app/tests/Unit/Advertising/CreativeValidatorTest.php new file mode 100644 index 00000000..44c2d711 --- /dev/null +++ b/app/tests/Unit/Advertising/CreativeValidatorTest.php @@ -0,0 +1,81 @@ +validateText(str_repeat('а', 57), 'ok text'))->toHaveKey('title'); // >56 → ошибка + expect($v->validateText('Норм заголовок', str_repeat('б', 82)))->toHaveKey('text'); // >81 + expect($v->validateText('Норм заголовок', 'Короткий текст'))->toBe([]); // ок +}); + +it('rejects title with a single word longer than 22 characters', function () { + $v = new CreativeValidator; + + $longWord = str_repeat('ё', 23); + expect($v->validateText($longWord, 'Короткий текст'))->toHaveKey('title'); + + $okWord = str_repeat('ё', 22); + expect($v->validateText($okWord, 'Короткий текст'))->toBe([]); +}); + +it('rejects text with a single word longer than 23 characters', function () { + $v = new CreativeValidator; + + $longWord = str_repeat('ж', 24); + expect($v->validateText('Норм заголовок', $longWord))->toHaveKey('text'); + + $okWord = str_repeat('ж', 23); + expect($v->validateText('Норм заголовок', $okWord))->toBe([]); +}); + +it('accepts title2 within limit (30 normal + up to 15 narrow) and rejects too-long', function () { + $v = new CreativeValidator; + + // 30 обычных символов + 15 узких (кавычки) — на грани, должно быть ок + $okTitle2 = str_repeat('а', 30).str_repeat('"', 15); + expect($v->validateText('Норм заголовок', 'Короткий текст', $okTitle2))->toBe([]); + + // 31 обычный символ сверх лимита → ошибка + $tooLongTitle2 = str_repeat('а', 31); + expect($v->validateText('Норм заголовок', 'Короткий текст', $tooLongTitle2))->toHaveKey('title2'); + + // узких больше 15 → ошибка + $tooManyNarrow = str_repeat('а', 20).str_repeat('"', 16); + expect($v->validateText('Норм заголовок', 'Короткий текст', $tooManyNarrow))->toHaveKey('title2'); +}); + +it('accepts text within limit (81 normal + up to 15 narrow) and rejects too-long', function () { + $v = new CreativeValidator; + + // 4 слова (23+23+23+8=77 + 3 пробела между ними = 80) + 1 разделительный пробел (81 обычный) + // + отдельное «слово» из 15 узких символов (не считается в лимит обычных). + $okText = implode(' ', [str_repeat('б', 23), str_repeat('б', 23), str_repeat('б', 23), str_repeat('б', 8)]) + .' '.str_repeat('!', 15); + expect($v->validateText('Норм заголовок', $okText))->toBe([]); + + $tooLongText = str_repeat('б', 82).str_repeat('!', 15); + expect($v->validateText('Норм заголовок', $tooLongText))->toHaveKey('text'); +}); + +it('validates image dimensions, weight and mime', function () { + $v = new CreativeValidator; + + // корректная обычная картинка + expect($v->validateImage(600, 600, 'image/jpeg', 500_000))->toBe([]); + + // слишком маленькая + expect($v->validateImage(200, 200, 'image/jpeg', 500_000))->toHaveKey('image'); + + // слишком тяжёлая (>10 МБ) + expect($v->validateImage(600, 600, 'image/jpeg', 11 * 1024 * 1024))->toHaveKey('image'); + + // неверный mime + expect($v->validateImage(600, 600, 'image/webp', 500_000))->toHaveKey('image'); +}); diff --git a/app/tests/Unit/Advertising/YandexDirectClientTest.php b/app/tests/Unit/Advertising/YandexDirectClientTest.php new file mode 100644 index 00000000..ecce7e6e --- /dev/null +++ b/app/tests/Unit/Advertising/YandexDirectClientTest.php @@ -0,0 +1,36 @@ + Http::response(['result' => ['AddResults' => [['Id' => 777]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addRetargetingList('Лидерра кампания #5', 58034825); + + expect($id)->toBe(777); + Http::assertSent(function ($req) { + $body = $req->data(); + + return str_ends_with($req->url(), '/json/v5/retargetinglists') + && $req->hasHeader('Authorization', 'Bearer TESTTOKEN') + && $body['method'] === 'add' + && $body['params']['RetargetingLists'][0]['Type'] === 'AUDIENCE' + && $body['params']['RetargetingLists'][0]['Rules'][0]['Operator'] === 'ALL' + && $body['params']['RetargetingLists'][0]['Rules'][0]['Arguments'][0]['ExternalId'] === 58034825; + }); +}); + +it('surfaces an API error as RuntimeException', function () { + Http::fake(['*/json/v5/campaigns' => Http::response(['error' => ['error_string' => 'Нет доступа к API', 'error_code' => 53]], 200)]); + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'T'); + expect(fn () => $client->addCpmBannerCampaign('C', '2026-08-01', '2026-08-14', 72000000, 90000000, 3, 14))->toThrow(RuntimeException::class); +}); diff --git a/app/tests/Unit/Advertising/YandexDirectConfigTest.php b/app/tests/Unit/Advertising/YandexDirectConfigTest.php new file mode 100644 index 00000000..f56bf878 --- /dev/null +++ b/app/tests/Unit/Advertising/YandexDirectConfigTest.php @@ -0,0 +1,18 @@ +toContain('api-sandbox.direct.yandex.com') + ->and(config('services.yandex_direct.enabled'))->toBeFalse(); +}); + +it('exposes cpm media defaults and keeps switch off', function () { + expect(config('services.yandex_direct.enabled'))->toBeFalse() + ->and(config('services.yandex_direct.region_ids'))->toBe([225]) + ->and(config('services.yandex_direct.spend_limit_guard_multiplier'))->toBe(1.2); +}); diff --git a/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php b/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php new file mode 100644 index 00000000..296666ba --- /dev/null +++ b/app/tests/Unit/Advertising/YandexDirectMediaClientTest.php @@ -0,0 +1,277 @@ + Http::response(['result' => ['AddResults' => [['Id' => 777]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addCpmBannerCampaign( + 'Лидерра медийная #1', + '2026-07-26', + '2026-08-26', + 50_000_000, + 1_000_000_000, + 3, + 7, + ); + + expect($id)->toBe(777); + Http::assertSent(function ($req) { + $body = $req->data(); + $campaign = $body['params']['Campaigns'][0]; + $strategy = $campaign['CpmBannerCampaign']['BiddingStrategy']; + $cpMax = $strategy['Network']['CpMaximumImpressions']; + $freq = $campaign['CpmBannerCampaign']['FrequencyCap']; + + return $body['method'] === 'add' + && $campaign['Name'] === 'Лидерра медийная #1' + && $campaign['StartDate'] === '2026-07-26' + && $campaign['EndDate'] === '2026-08-26' + && $strategy['Search']['BiddingStrategyType'] === 'SERVING_OFF' + && $strategy['Network']['BiddingStrategyType'] === 'CP_MAXIMUM_IMPRESSIONS' + && $cpMax['AverageCpm'] === 50_000_000 + && $cpMax['SpendLimit'] === 1_000_000_000 + && $cpMax['StartDate'] === '2026-07-26' + && $cpMax['EndDate'] === '2026-08-26' + && $cpMax['AutoContinue'] === 'NO' + && $freq['Impressions'] === 3 + && $freq['PeriodDays'] === 7; + }); +}); + +it('sends auto continue yes when requested for the cpm banner campaign', function () { + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 778]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $client->addCpmBannerCampaign( + 'Лидерра медийная #2', + '2026-07-26', + '2026-08-26', + 50_000_000, + 1_000_000_000, + 3, + 7, + 'YES', + ); + + Http::assertSent(function ($req) { + $body = $req->data(); + + return $body['params']['Campaigns'][0]['CpmBannerCampaign']['BiddingStrategy']['Network']['CpMaximumImpressions']['AutoContinue'] === 'YES'; + }); +}); + +it('adds an empty cpm banner ad group with an empty keywords object', function () { + Http::fake([ + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 555]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addCpmBannerAdGroup(777, 'Группа медийная', [1, 213]); + + expect($id)->toBe(555); + Http::assertSent(function ($req) { + $body = $req->data(); + $group = $body['params']['AdGroups'][0]; + + $matches = $body['method'] === 'add' + && $group['Name'] === 'Группа медийная' + && $group['CampaignId'] === 777 + && $group['RegionIds'] === [1, 213]; + + // Пустой JSON-объект {}, а не массив [] + $decoded = json_decode($req->body()); + $matches = $matches && $decoded->params->AdGroups[0]->CpmBannerKeywordsAdGroup instanceof stdClass; + + return $matches && str_contains($req->body(), '"CpmBannerKeywordsAdGroup":{}'); + }); +}); + +it('adds a media audience target without a context bid', function () { + Http::fake([ + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 999]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addMediaAudienceTarget(555, 58034825); + + expect($id)->toBe(999); + Http::assertSent(function ($req) { + $body = $req->data(); + $target = $body['params']['AudienceTargets'][0]; + + return $body['method'] === 'add' + && $target['AdGroupId'] === 555 + && $target['RetargetingListId'] === 58034825 + && ! array_key_exists('ContextBid', $target); + }); +}); + +it('adds a cpm banner ad by creative id', function () { + Http::fake([ + '*/json/v5/ads' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $id = $client->addCpmBannerAd(555, 4242, 'https://liderra.ru/promo'); + + expect($id)->toBe(111); + Http::assertSent(function ($req) { + $body = $req->data(); + $ad = $body['params']['Ads'][0]; + + return $body['method'] === 'add' + && $ad['AdGroupId'] === 555 + && $ad['CpmBannerAdBuilderAd']['Creative']['CreativeId'] === 4242 + && $ad['CpmBannerAdBuilderAd']['Href'] === 'https://liderra.ru/promo'; + }); +}); + +it('parses a creative preview into an array with is_adaptive true when YES', function () { + Http::fake([ + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [[ + 'Id' => 4242, + 'Type' => 'HTML5_CREATIVE', + 'PreviewUrl' => 'https://yastatic.net/preview/4242', + 'ThumbnailUrl' => 'https://yastatic.net/thumb/4242', + 'IsAdaptive' => 'YES', + 'Width' => 300, + 'Height' => 250, + ]]]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $preview = $client->getCreativePreview(4242); + + expect($preview)->toBe([ + 'id' => 4242, + 'type' => 'HTML5_CREATIVE', + 'preview_url' => 'https://yastatic.net/preview/4242', + 'thumbnail_url' => 'https://yastatic.net/thumb/4242', + 'is_adaptive' => true, + 'width' => 300, + 'height' => 250, + ]); + + Http::assertSent(function ($req) { + $body = $req->data(); + + return $body['method'] === 'get' + && $body['params']['SelectionCriteria']['Ids'] === [4242] + && $body['params']['FieldNames'] === ['Id', 'Type', 'PreviewUrl', 'ThumbnailUrl', 'IsAdaptive', 'Width', 'Height']; + }); +}); + +it('returns null from creative preview when the creative is not found', function () { + Http::fake([ + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]], 200), + ]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $preview = $client->getCreativePreview(9999); + + expect($preview)->toBeNull(); +}); + +it('lists image creative ids with their sizes and ignores video creatives', function () { + Http::fake([ + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 1163441165, 'Type' => 'VIDEO_EXTENSION_CREATIVE', 'Width' => 1080, 'Height' => 1080], + ['Id' => 1163586673, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ['Id' => 1163586680, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + ]); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + // Картиночные креативы Яндекс хранит типом HTML5_CREATIVE даже для загруженных JPG — + // проверено живым запросом 27.07.2026. Видео-креативы в набор попадать не должны. + expect($client->listImageCreativeIds())->toBe([ + 1163586673 => [300, 250], + 1163586680 => [728, 90], + ]); +}); + +/** + * Слепок креативов — это опись всего аккаунта, и Яндекс отдаёт её страницами: больше + * десяти тысяч объектов за раз он не отдаст никогда, а признак «отдал не всё» — поле + * `LimitedBy` в ответе. Раньше страницы не листались вовсе: слепок молча обрывался на + * первой. Дальше опознание креативов сравнивает «до» и «после» — при оборванном слепке + * креатив, лежащий за границей страницы, выглядел бы «новым» на каждом заходе. + */ +it('walks through all pages of the creatives snapshot', function () { + $pages = [ + // Первая страница: LimitedBy говорит «отдал не всё, продолжай с этого места». + ['result' => [ + 'Creatives' => [['Id' => 11, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250]], + 'LimitedBy' => 1, + ]], + // Вторая: LimitedBy больше нет — это конец описи. + ['result' => [ + 'Creatives' => [['Id' => 22, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90]], + ]], + ]; + + $offsets = []; + Http::fake(function ($request) use (&$pages, &$offsets) { + $offsets[] = $request['params']['Page']['Offset'] ?? null; + + return Http::response(array_shift($pages) ?? ['result' => ['Creatives' => []]]); + }); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + expect($client->listImageCreativeIds())->toBe([ + 11 => [300, 250], + 22 => [728, 90], + ]); + + // Ровно два обращения: второе — с продолжением с того места, где Яндекс оборвал первое. + expect($offsets)->toBe([0, 1]); +}); + +it('returns an empty snapshot when the account has no creatives', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => []])]); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + expect($client->listImageCreativeIds())->toBe([]); +}); + +it('удаление объявлений уходит одним запросом со всеми номерами', function () { + Http::fake(['*' => Http::response(['result' => ['DeleteResults' => [['Id' => 11], ['Id' => 12]]]])]); + + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + $client->deleteAds([11, 12]); + + Http::assertSent(function ($req) { + $body = $req->data(); + + return str_contains($req->url(), 'v5/ads') + && $body['method'] === 'delete' + && $body['params']['SelectionCriteria']['Ids'] === [11, 12]; + }); +}); + +/** + * Пустое дело Яндексу не отправляем: он ответил бы ошибкой, а мы приняли бы её + * за настоящую беду и написали в журнал тревогу на ровном месте. + */ +it('пустой список объявлений в Яндекс не уходит', function () { + Http::fake(); + + (new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'))->deleteAds([]); + + Http::assertNothingSent(); +}); diff --git a/bots/yandex-creatives/.env.example b/bots/yandex-creatives/.env.example new file mode 100644 index 00000000..5892a415 --- /dev/null +++ b/bots/yandex-creatives/.env.example @@ -0,0 +1,19 @@ +YC_BROWSER_PROFILE_DIR=/var/lib/liderra-creative-robot/profile +YC_CABINET_URL=https://direct.yandex.ru +# Логин рекламного кабинета — идёт в адресах Директа параметром ulogin. +YC_CABINET_LOGIN= +# «Прихожая»: существующие кампания и группа, форму нового объявления которых робот +# открывает только чтобы дойти до окна загрузки. Ничего в них не сохраняется. +YC_ANTEROOM_CAMPAIGN_ID= +YC_ANTEROOM_GROUP_ID= +PORTAL_BASE_URL=https://lk.liderra.ru +CREATIVE_ROBOT_TOKEN= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +ALARM_FROM=robot@liderra.ru +ALARM_TO=ops@liderra.ru +HUMAN_DELAY_MS=800 +# Пауза между повторами доклада порталу «готово» после удачной загрузки. +REPORT_RETRY_DELAY_MS=3000 diff --git a/bots/yandex-creatives/.gitignore b/bots/yandex-creatives/.gitignore new file mode 100644 index 00000000..e7453f78 --- /dev/null +++ b/bots/yandex-creatives/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +screenshots/ +downloads/ +.env +robot.lock diff --git a/bots/yandex-creatives/README.md b/bots/yandex-creatives/README.md new file mode 100644 index 00000000..1fd9df77 --- /dev/null +++ b/bots/yandex-creatives/README.md @@ -0,0 +1,100 @@ +# Робот-грузчик креативов в кабинет Яндекс Директа + +## Зачем он нужен + +Портал заводит рекламную кампанию «за показы» в Яндексе целиком через программный +интерфейс — кроме одного шага. **Картиночный креатив через него создать нельзя:** приём +`creatives.add` принимает только видео, а конструктор креативов Яндекс закрыл 01.06.2026. +Остался единственный путь — загрузить готовые файлы руками в веб-кабинете. + +Этот робот и есть «руки»: он относит готовые файлы клиента в кабинет и говорит порталу +«готово». Всё остальное портал делает сам. + +## Что робот делает + +1. Спрашивает у портала, есть ли работа. +2. Скачивает файлы баннеров. +3. Открывает кабинет в своём браузере и проверяет, жив ли вход. +4. Загружает файлы через «Загрузить креативы». +5. Отчитывается порталу и чистит за собой скачанные картинки. + +## Чего робот НЕ делает + +- **Не создаёт кампаний и не запускает показы.** +- **Не тратит деньги** — он вообще не может: таких действий у него нет. +- **Не читает номера созданных креативов** — их портал добывает сам, сравнивая список + креативов кабинета до и после загрузки. +- **Не нажимает «Сохранить изменения»** — единственную кнопку, которая меняет кабинет. + На это есть отдельный тест-сторож. + +При любом сбое робот останавливается, отчитывается порталу «сбой» и шлёт письмо со +снимком экрана. Кампания при этом остаётся черновиком. + +## Настройка + +Скопировать `.env.example` в `.env` и заполнить. Отдельного пояснения стоят: + +- `YC_CABINET_LOGIN` — логин рекламного кабинета. +- `YC_ANTEROOM_CAMPAIGN_ID` и `YC_ANTEROOM_GROUP_ID` — **«прихожая»**: существующие + кампания и группа, форму нового объявления которых робот открывает только чтобы + добраться до окна загрузки. Ничего в них не сохраняется. Своей кампании у робота в этот + момент ещё нет: портал ставит задание **до** создания кампании в Яндексе, потому что + кампания заводится сразу с готовыми номерами креативов. Надёжнее всего назначить + прихожей отдельную остановленную кампанию — тогда робот не приближается к работающей + рекламе вовсе. + +## Как завести вход в кабинет + +Вход заводится **один раз глазами** и дальше живёт в папке профиля браузера: + +```bash +npm run login +``` + +На боевом сервере экрана нет, поэтому команда запускается внутри виртуального рабочего +стола, к которому подключаются по удалённому рабочему столу через SSH-туннель. Наружу +порт не открывается. Владелец вводит пароль и код из СМС — профиль сохраняется на диск. + +## Как запускать + +```bash +npm run run:once # один проход: взять задание, если оно есть +npm run keepalive # тихо зайти в кабинет, чтобы вход не заснул +npm test # проверки, браузер не нужен +``` + +По расписанию: `run:once` — часто, `keepalive` — раз в ~15 минут. + +Наложиться друг на друга они не могут: браузерный профиль один, и оба запуска берут общий +файл-замок `robot.lock` в папке робота. Занят замок — процесс молча уходит с кодом 0 и +пробует в следующий раз; задание при этом остаётся в очереди. Замок, брошенный убитым +процессом (перезагрузка сервера), перехватывается через полчаса. + +Снимки экрана в папке `screenshots/` робот убирает сам: старше двух недель — удаляет при +очередном запуске. Свежие нужны, чтобы разобрать вчерашний сбой; лежать вечно им нельзя — +на снимке видна боковая панель кабинета с логином и остатком счёта. + +## Пришло письмо-алярм — что делать + +В письме указан шаг, на котором робот встал, и приложен снимок экрана. + +- **«Вход в кабинет слетел»** — зайти по удалённому рабочему столу и выполнить `npm run login`. +- **«Кабинет не принял файлы»** — посмотреть снимок: скорее всего, файл не тех размеров + или слишком тяжёлый. +- **«Окно загрузки не закрылось»** — в письме будет текст, который показал сам кабинет. + Задание при этом закрыто как выполненное: правду об успехе знает портал, он сверяет + список креативов до и после. Если креативы не появились, портал сам пометит задание сбойным. + +🔑 Смотрите на концовку письма — она честно говорит, что происходило в кабинете: + +- **«Робот остановился и ничего в кабинете не менял»** — работа не начиналась, кампания + осталась черновиком, деньги не потрачены. В кабинет идти незачем. +- **«Креативы в кабинет уже загружены»** — робот дошёл до конца, файлы лежат в кабинете. + Загляните туда и в очередь заданий: повторная заливка оставит дубли, а вычистить их + можно только руками. + +## Где почитать подробности + +- Разметка экранов кабинета, снятая живьём — [docs/cabinet-flow.md](docs/cabinet-flow.md). +- Замысел целиком — `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +- Почему конструктор закрыт — `docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/`. diff --git a/bots/yandex-creatives/bin/keepalive.js b/bots/yandex-creatives/bin/keepalive.js new file mode 100644 index 00000000..9678c9df --- /dev/null +++ b/bots/yandex-creatives/bin/keepalive.js @@ -0,0 +1,32 @@ +import { loadEnvFile } from '../src/env.js'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; +import { acquireLock, robotLockPath } from '../src/lock.js'; + +// Настройки читаем из .env РОБОТА, а не того каталога, откуда его запустило расписание. +loadEnvFile(); + +// Тихий заход, чтобы вход не заснул. Запускается по расписанию раз в ~15 минут. +// Ничего не нажимает, ничего не меняет — только смотрит, жив ли вход. + +// Профиль браузера один, и Chromium держит его под своим замком: второй процесс просто +// не стартует. Проход робота и поддержание входа идут по расписанию и могут наложиться — +// поэтому занят замок, уходим МОЛЧА и с кодом 0: это не сбой, а обычное «сейчас занято». +const lock = acquireLock(robotLockPath()); +if (!lock.ok) { + console.log(JSON.stringify({ skipped: 'робот уже работает', at: new Date().toISOString() })); + process.exit(0); +} + +try { + const config = loadConfig(); + const { context, page } = await openBrowser(config, {}); + const ok = await isLoggedIn(page, config); + + await context.close(); + console.log(JSON.stringify({ loggedIn: ok, at: new Date().toISOString() })); + process.exit(ok ? 0 : 1); +} finally { + lock.release(); +} diff --git a/bots/yandex-creatives/bin/login.js b/bots/yandex-creatives/bin/login.js new file mode 100644 index 00000000..c25e4883 --- /dev/null +++ b/bots/yandex-creatives/bin/login.js @@ -0,0 +1,19 @@ +import { loadEnvFile } from '../src/env.js'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn, overviewUrl } from '../src/session.js'; + +// Настройки читаем из .env РОБОТА, а не того каталога, откуда его запустило расписание. +loadEnvFile(); + +// Разовый заход глазами: на боевом запускается внутри виртуального экрана через +// удалённый рабочий стол. Владелец вводит пароль и СМС, профиль остаётся на диске. +const config = loadConfig(); +const { context, page } = await openBrowser(config, { headless: false }); + +await page.goto(overviewUrl(config)); +console.log('Войди в кабинет в открывшемся окне. Когда войдёшь — нажми Enter здесь.'); +await new Promise((resolve) => process.stdin.once('data', resolve)); + +console.log((await isLoggedIn(page, config)) ? 'Вход есть.' : 'Вход НЕ виден — проверь ещё раз.'); +await context.close(); diff --git a/bots/yandex-creatives/bin/run.js b/bots/yandex-creatives/bin/run.js new file mode 100644 index 00000000..26bf98e5 --- /dev/null +++ b/bots/yandex-creatives/bin/run.js @@ -0,0 +1,40 @@ +import { loadEnvFile } from '../src/env.js'; +import { loadConfig } from '../src/config.js'; +import { createPortal } from '../src/portal.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; +import { readRejection, uploadCreatives } from '../src/cabinet.js'; +import { createMailer } from '../src/mailer.js'; +import { smtpTransport } from '../src/smtp.js'; +import { runOnce } from '../src/runner.js'; +import { acquireLock, robotLockPath } from '../src/lock.js'; + +// Настройки читаем из .env РОБОТА, а не того каталога, откуда его запустило расписание. +loadEnvFile(); + +// Один проход: взять задание, если оно есть, и довести до конца. Запускается расписанием. + +// 🔴 Замок берём ДО того, как спросить у портала работу. Профиль браузера один, Chromium +// держит его под своим замком, и второй процесс просто не стартует — а падало это уже +// ПОСЛЕ выдачи задания, и задание помечалось сбойным, хотя ничего не сломано. Занят +// замок — уходим молча и с кодом 0, задание остаётся в очереди до следующего раза. +const lock = acquireLock(robotLockPath()); +if (!lock.ok) { + console.log(JSON.stringify({ skipped: 'робот уже работает' })); + process.exit(0); +} + +try { + const config = loadConfig(); + const portal = createPortal(config); + const browser = { open: openBrowser, isLoggedIn, uploadCreatives, readRejection }; + const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + const res = await runOnce(config, portal, browser, mailer, { timestamp }); + + console.log(JSON.stringify(res, null, 2)); + process.exit(res.idle || res.ok ? 0 : 1); +} finally { + lock.release(); +} diff --git a/bots/yandex-creatives/docs/cabinet-flow.md b/bots/yandex-creatives/docs/cabinet-flow.md new file mode 100644 index 00000000..a94785a6 --- /dev/null +++ b/bots/yandex-creatives/docs/cabinet-flow.md @@ -0,0 +1,509 @@ +# Разметка кабинета Яндекс Директа — экраны загрузки креативов + +**Снято живьём 27.07.2026** в боевом кабинете `sasha261185` вместе с владельцем. +Ничего не сохранялось, ничего не создавалось: до и после — ровно 15 объявлений в группе +`5776749172`, кнопка «Сохранить изменения» не нажималась ни разу. + +Источник задачи — план `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md`, Task 11. +Снимок экрана — `docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/2026-07-27-ekran-zagruzki-kreativov.png` +(боковая панель на снимке намеренно скрыта: в кадр попадали логин и остаток счёта). + +--- + +## 0. Главный вывод для Task 14 + +**Системное окно «Выберите файл» роботу не нужно.** В форме объявления лежит обычное +`` с `data-testid="CreativeActionsMenu.FileInput"`, +`accept="image/png,image/jpeg,image/jpg,image/gif"`, `multiple`. Playwright кладёт файлы +прямо в него (`locator.setInputFiles`), диалог операционной системы не открывается. + +> 🔴 **ПОПРАВКА 28.07.2026 — этот абзац неверен в главном.** Файлы, положенные +> в `CreativeActionsMenu.FileInput`, Яндекс **не принимает**: поле их молча забирает, +> а кнопка «Создать» остаётся серой. Настоящее поле — **внутри окна загрузки**, +> в площадке перетаскивания (`.html5-dropzone__dropzone input[type=file]`, своей метки +> у него нет). Проверено живой загрузкой — см. §5. Абзац оставлен как есть, чтобы +> не потерять, откуда взялась ошибка: 27.07 файлов не грузили вовсе, вывод сделан +> по виду разметки. + +Весь путь до окна загрузки **проходится программно** — проверено живьём: последовательность +кликов ниже была выполнена скриптом, окно открылось. + +--- + +## 1. Признак «вход в кабинет жив» + +| Что | Значение | +|---|---| +| Страница проверки | `https://direct.yandex.ru/wizard/overview/?ulogin=<логин>` | +| Устойчивый признак | элемент `[data-testid="Sidebar.BalanceExpanded"]` — блок баланса с кнопкой «Пополнить» | +| Запасной признак | `[data-testid="SidebarUserControl"]` — содержит текст логина | +| Признак «вход слетел» | адрес после перехода начинается с `https://passport.yandex.ru/` **или** блок баланса не появился за 15 с | + +Проверять **оба** условия: редирект на `passport.yandex.ru` — самый надёжный сигнал, отсутствие +блока баланса ловит случай «страница отдалась, но кабинет не авторизован». + +--- + +## 2. Путь до окна загрузки + +Все шаги — на одной странице, переходов между адресами внутри пути нет. + +### Шаг 1. Открыть форму нового объявления + +```text +https://direct.yandex.ru/dna/banners-edit?ulogin=<логин>&campaigns-ids=<номер кампании>&groups-ids=<номер группы>&is-new=1 +``` + +- Успех: появился `[data-testid="BannersEditPage"]`, внутри — заголовок «Баннер» и метка «Черновик». +- Ждать: до 20 с (форма догружается отдельно от каркаса страницы, каркас появляется раньше). + +🔴 **Почему именно форма НОВОГО объявления, а не существующего.** Пока не нажата «Сохранить +изменения», в кабинете ничего не появляется. Робот использует эту форму только как «прихожую» +к окну загрузки: он грузит креативы и уходит, объявление не создаётся. Лезть в существующее +живое объявление нельзя — там можно случайно испортить работающую рекламу. + +### Шаг 2. Открыть меню креатива + +- Кнопка: `[data-testid="ImageCreativeEditor.OpenMenu"]` — подпись «Добавить», + внутри блока `[data-testid="ImageCreativeEditor"]` (заголовок «Креатив»). +- Успех: появилось меню с заголовком «Готовые креативы HTML5». +- Ждать: до 5 с. + +### Шаг 3. Выбрать «Загрузить креативы» + +Пункты меню: + +| Подпись | Селектор | +|---|---| +| **Загрузить креативы** | `[data-testid="ImageCreativeEditor.Menu.add-html5"]` | +| Выбрать из ранее загруженных | тот же префикс `ImageCreativeEditor.Menu.*`; роботу не нужен | + +- Успех: появилось окно `[data-testid="CanvasModal.html5"]`. +- Ждать: до 10 с. + +### Шаг 4. Окно загрузки + +Содержимое окна `[data-testid="CanvasModal.html5"]`: + +| Элемент | Селектор | Заметка | +|---|---|---| +| Вкладки | `role="tab"` с именами «Мои креативы» / «Загрузить» / «Архивные» | **своих `data-testid` у вкладок нет** — искать по роли и имени; «Загрузить» выбрана по умолчанию, переключать не надо | +| Имя будущего креатива | `[data-testid="CanvasNameLabel"]` | текст «Новый креатив» | +| Карандашик «переименовать» | кнопка `[data-testid="Button"]` внутри `CanvasNameLabel`, класс `canvas-editable-label__btn_action_edit-name` | см. §4 | +| Площадка «перетащите файлы» | текст «Перетащите файлы с баннерами сюда» | | +| Кнопка «Загрузить» | `[data-testid="Html5Dropzone.UploadButton"]` — это `div`, не `button` | роботу **не нужна**: он кладёт файлы прямо в `input` | +| Поле файлов | 🔴 **брать не это** — `[data-testid="CreativeActionsMenu.FileInput"]` лежит в форме и файлы молча глотает | `accept="image/png,image/jpeg,image/jpg,image/gif"`, `multiple`; рабочее поле — в §5.2 | +| Кнопка «Создать» | `[data-testid="CanvasFooter.SubmitButton"]` | **серая, пока не выбраны файлы** — это и есть проверка «файлы приняты» | +| Кнопка «Отмена» | `[data-testid="CanvasFooter.CancelButton"]` — это `div` | | +| Крестик «закрыть» | `[data-testid="CanvasModal.html5.CloseButton"]` | | + +Внутри окна есть **второй** `input[type=file]` (у площадки перетаскивания) — без `data-testid` +и без `accept`. 🔴 **Поправка 28.07.2026: работать надо именно с ним** — это единственное поле, +файлы в котором Яндекс принимает. Отбирать по принадлежности окну: +`[data-testid="CanvasModal.html5"] input[type=file]`. Прежнее указание брать +`CreativeActionsMenu.FileInput` неверно, см. §5.2. + +### Шаг 5. Что означает успех загрузки + +Порядок проверок для робота: + +1. После `setInputFiles` — дождаться, что `[data-testid="CanvasFooter.SubmitButton"]` **перестал + быть заблокированным** (`disabled` снят). Это признак «Яндекс файлы принял». +2. Нажать «Создать». +3. Успех создания подтверждать **не по экрану, а по API**: портал берёт слепок `creatives.get` + и сверяет разницу (см. `CreativeIdMatcher`). Экран может соврать, слепок — нет. + +⚠️ **Не измерено на живом** (файлов не грузили, чтобы ничего не создавать): сколько ждать +обработки после «Создать» и как выглядит ошибка «файл не подошёл». Это добирается в Task 14 +на первом настоящем прогоне, до включения рубильника. + +### Шаг 6. Уход без следа + +Закрыть окно крестиком и **уйти со страницы, не нажимая `[data-testid="SaveBannerButton"]`** +(«Сохранить изменения»). Черновик объявления никуда не сохраняется. + +🔴 `SaveBannerButton` — единственная кнопка на этом пути, которая меняет кабинет. +Робот не должен нажимать её никогда. + +--- + +## 3. Что подтвердилось живьём + +- **Конструктор креативов закрыт.** Яндекс сам пишет это в форме, блок + `[data-testid="CreativeCanvasSunsetNotice"]`: «Конструктор креативов закрылся 1 июня. + С 30 июня показы объявлений с креативами, созданными через конструктор, остановлены». +- **Медийная кампания = объявление на каждый размер.** В группе `5776749172` кампании + `713051718` — ровно 15 объявлений, номера `17786692522`–`17786692536`, размеры: + 336×280, 480×320, 160×600, 320×50, 320×100, 300×250, 320×480, 300×500, 970×250, + 1000×120, 240×600, 240×400, 728×90, 300×300, 300×600. +- **Имена для опознания не годятся.** У всех 15 подпись «Новый креатив» — это имя по умолчанию + из поля `CanvasNameLabel`, и оно **одно на всю загрузку**: владелец залил все файлы разом, + поэтому имя у всех совпало. Опознание по разнице слепков и размеру остаётся единственным + надёжным способом. +- Список объявлений в левой колонке формы: `[data-testid="NavigationItem.Banner.<номер>"]`, + текст вида «336x280, Новый креатив». + +--- + +## 4. Открытый вопрос — имя креатива можно задавать + +У поля имени есть карандашик (`canvas-editable-label__btn_action_edit-name`), то есть креатив +**можно назвать своим именем** — например `liderra-<кампания>-300x250`. Если грузить по одному +файлу за раз и каждому давать своё имя, опознание стало бы прямым, без сверки слепков. + +**Решение не принято, ядро не трогаем.** Сверка слепков уже написана, покрыта тестами и +работает; переход на имена — это переделка готового куска ради удобства. Записано как +возможность, решать владельцу. Минусы перехода: грузить придётся по одному файлу вместо пачки +(дольше), и мы начнём зависеть от того, что Яндекс не подрежет и не подменит наше имя. + +--- + +## 5. Снято живьём 28.07.2026 — создание медийной кампании и настоящая загрузка креативов + +Повод: чтобы увидеть экран **отказа** модерации, отказ надо вызвать. Приём взят из кабинета +МТС (идея владельца, сработала там 27–28.07): реклама **регулируемой тематики** — займ под +залог. Модерация обязана придраться и потребовать документы. + +Заведена отдельная **пустышка**: медийная кампания № 713110757 «Медийная кампания №3 +от 28-07-2026», срок **01.10–07.10.2026** (показов сейчас быть не может физически), +бюджет 3 000 ₽, расход 0,00 ₽. Живую кампанию № 713051718 не трогали. + +### 5.1. Путь мастера создания кампании + +| Шаг | Адрес / метка | +|---|---| +| Список типов | кнопка `[data-testid="GridToolbox.AddEntityButton"]` на `/dna/grid/campaigns`; раздел «Охватные кампании» раскрывается кликом | +| Наш тип | `/dna/campaigns-edit?ulogin=<логин>&is-new=1&campaign-type=**CPM_BANNER**` — «Баннеры» | +| Продвигаемая страница | `[data-testid="CampaignLinkEditor.LinkInput.Textinput"]` — это **не `input`**, а блок с `role=textbox`; Playwright `fill()` работает | +| Дальше | `[data-testid="CampaignsEditPageFooter.ContinueButton"]` | +| Стратегия | `[data-testid="StrategyTypeField"]` → `[data-testid="StrategyTypeField.ListBox.MAX_SHOWS"]` | +| Бюджет / срок / цена | `BudgetField.Input`, `PeriodField.Start.DatePicker.input`, `PeriodField.End.DatePicker.input`, `AvgCpmField.Input` | +| Группа | `/dna/groups-edit?...&is-new=1&is-new-empty-group=1&linear-creation=1`; регион — быстрая кнопка `[data-testid="RegionsTreeEditorShortcut"]`; подбор аудитории по умолчанию «Профиль пользователя» — отдельный сегмент **не обязателен** | +| Объявление | `/dna/banners-edit?...&is-new=1` — та же форма, что в §2 | + +🪤 **«Продолжить» на шаге настроек уже создаёт кампанию** — всплывает «Сохранено», +кампания получает номер и статус «Черновик». Это не предпросмотр. + +### 5.2. Загрузка креативов — как на самом деле + +1. `[data-testid="ImageCreativeEditor.OpenMenu"]` → меню. +2. Пункты меню: `ImageCreativeEditor.Menu.add-html5` («Загрузить креативы») и + `ImageCreativeEditor.Menu.select-html5` («Выбрать из ранее загруженных»). + 🪤 Брать надо именно эти метки: у самого меню метка `ImageCreativeEditor.Menu`, и клик + по ней открывает окно загрузки, а не то, что просили. +3. Окно `[data-testid="CanvasModal.html5"]`. +4. 🔑 **Файлы кладём в поле ВНУТРИ окна**: `[data-testid="CanvasModal.html5"] input[type=file]` + (площадка перетаскивания). Признак «Яндекс принял» — `CanvasFooter.SubmitButton` + («Создать») перестала быть серой, а в окне появились размеры: «300×250», «728×90». +5. «Создать» — окно **не закрывается**, а переключается на вкладку «Мои креативы» + со списком наборов. Загруженное само в объявление не встаёт. +6. 🔑 **Выбор набора — это ГАЛОЧКА `input[data-testid="BatchesList"]`** (скрытая, нужен + `check({force:true})`), а не соседняя кнопка `BatchesList.Controls.Select`. Пока галочка + не стоит, «Добавить выбранные» (`CanvasFooter.SubmitButton`) серая. Список идёт + «Сначала новые». Рядом — `BatchesList.Delete` (удалить набор). +7. После «Добавить выбранные» окно закрывается, в форме появляется «Формат баннера: 300×250». +8. Ссылка объявления — `[data-testid="LinkInput.Textinput"]`, тоже блок, `fill()` работает. + Без неё сохранение молча не проходит. +9. `[data-testid="SaveBannerButton"]` — сохранить. Объявление получает номер и статус + «Черновик» (у нас № 17787055204). + +### 5.3. Отправка на модерацию + +- Кнопка `[data-testid="StartCampaignButton"]` («Запустить кампанию») — в подвале формы + объявления, рядом с «Сохранить изменения». Окна подтверждения нет. +- 🪤 **Экран после нажатия врёт**: всё ещё показывает «Черновик», а сама кнопка исчезает, + пока формируется «Текст описания для передачи в ЕРИР». Правду говорит **список кампаний**: + там уже «Группы кампании находятся на модерации / На модерации». Робот должен проверять + статус по списку, а не по форме. +- Для медийной кампании отдельного шага оплаты, как у МТС, **нет**: деньги списываются + по факту показов, а до начала срока показов нет. + +### 5.4. 🪤 Список объявлений врёт про пустоту + +`/dna/grid/banners?...&campaigns-ids=<номер>` по умолчанию стоит на «Последние 30 дней» +и на кампанию со сроком в будущем отдаёт **«Нет объявлений по заданным параметрам»** — +хотя объявление создано и лежит на модерации. Робот, который ищет объявление по номеру +в этом списке, получит честный ноль и решит, что объявления нет. + +Смотреть статус объявления надо на форме объявления +(`/dna/banners-edit?...&banners-ids=<номер>`), а статус кампании — в списке кампаний +со `status-filter=ALL`. Это тот же класс ошибки, что «тихий ноль» у RLS: пусто по фильтру +неотличимо от пусто по существу. + +### 5.5. Чего ещё не видели + +~~Экран **отказа** и требование документов — ждём вердикта по кампании 713110757.~~ +✅ Вердикт пришёл 28.07.2026, экран отказа снят — см. §7. Требования документов +в кабинете **не оказалось вовсе** — почему, там же. + +--- + +## 6. Чего этот файл НЕ покрывает + +- Поведение при ошибке загрузки (файл не тех размеров, слишком тяжёлый) — не проверялось. +- Сколько живёт вход в кабинет без действий — не проверялось. +- Лимиты Яндекса на число креативов и темп загрузки — не проверялись. +- Что происходит с уже загруженными креативами при перезаливке набора — открытый вопрос спеки §10. + +--- + +## 7. Экран отклонённого объявления — снято живьём 28.07.2026 + +Отказ вызван нарочно (реклама регулируемой тематики — займ под залог) на пустышке +№ 713110757, объявление № 17787055204. Вердикт пришёл на вторые сутки. +Снимок — `docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-razvernuto.png` +(снят один элемент окна: боковая панель с логином и остатком счёта в кадр не попала). + +### 7.1. 🔴 Три вывода, которые меняют работу + +1. **Причины отказа нет на форме объявления.** Форма + (`/dna/banners-edit?...&banners-ids=<номер>`) показывает только ярлык + `EntityStatus.StatusLabel.STOP_CRIT` «Показы не идут» и ни слова про модерацию. + Правду говорит **список объявлений**. Прежнее указание §5.4 «статус объявления смотреть + на форме» для отказа **не годится** — форма знает, что показов нет, но не знает почему. +2. **Кнопки «отправить на повторную модерацию» не существует.** В подвале формы только + `SaveBannerButton`. Яндекс пишет прямо: «Добавьте предупреждение, и объявление + **автоматически** отправится на повторную проверку». Повторная модерация = сохранение правки. +3. 🔴 **Приложить документ в кабинете НЕКУДА.** В окне отказа нет ни одного + `input[type=file]` — проверено программно, список полей пуст. Вместо этого две кнопки + `AggregatedStatusAction`: «Написать в чат» и «Написать письмо», обе ведут **наружу** + из кабинета (чат Яндекса и форма обратной связи `forms.yandex.ru`). Что это значит + для замысла — §7.5. + +### 7.2. Где лежит причина + +Только в списке объявлений: `/dna/grid/banners?ulogin=<логин>&campaigns-ids=<номер>&status-filter=ALL`. + +| Шаг | Метка | Что видно | +|---|---|---| +| Ячейка статуса в строке | `[data-testid="BannerStatusCell"]` | «Модератор отклонил объявление · Показы не идут» | +| Короткий статус | `[data-testid="AggregatedStatusContent"]` | «Модератор отклонил объявление» — **по нему кликать** | +| Приписка | `[data-testid="AggregatedStatusCaption"]` | «Показы не идут» | +| Ячейка целиком | `[data-testid="Grid.Cell-<номер объявления>_aggregated-status-info"]` | привязка к конкретному объявлению | + +Клик по `AggregatedStatusContent` открывает окно `[data-testid="BannerStatusCell.Popup"]`. +🪤 Наведение мышью окна **не открывает** — проверено, нужен именно клик. + +Внутри окна: + +| Метка | Что | +|---|---| +| `[data-testid="Text"]` (первый, `h3`) | заголовок «Модератор отклонил объявление» | +| `[data-testid="MiniGrid"]` | таблица причин | +| `[data-testid="RejectRow"]` | **одна причина** — краткая формулировка. Причин может быть несколько строк | +| `[data-testid="MiniGridRow"]` | подробное пояснение — **появляется только после раскрытия** | +| `[data-testid="AggregatedStatusAction"]` | кнопки «Написать в чат» / «Написать письмо» | + +🔑 **Строку причины надо раскрыть кликом по `RejectRow`** — до этого виден только +короткий заголовок причины, а всё пояснение скрыто. У свёрнутой строки справа шеврон вниз. + +### 7.3. Что Яндекс написал дословно + +Краткая причина (`RejectRow`): + +```text +Нет предупреждения: финансовые услуги +``` + +Подробное пояснение (`MiniGridRow`, после раскрытия) — целиком, как есть: + +```text +Объявление о финансовых услугах должно содержать наименование юридического лица +и его организационно-правовую форму. Например: «OOO "Дом на Марсе"». При продвижении +кредитных продуктов (например, кредита, займа, ипотеки) в объявлении дополнительно +должно быть предупреждение — «Изучите все условия кредита (займа) на сайте +в соответствующем разделе. Оцените свои финансовые возможности и риски». +Оно должно занимать не менее 10% площади баннера или видеоролика. + +Как пройти модерацию +Добавьте предупреждение, и объявление автоматически отправится на повторную проверку. +``` + +Ссылки в окне (постоянные, не зависят от причины): + +- «Требования к предупреждениям и возрастным ограничениям» → + `https://yandex.ru/support/direct/ru/moderation/age-alerts#image-ads-and-video` +- «Требования к рекламно-информационным материалам» → `https://yandex.ru/legal/general_adv_rules/` +- «Написать в чат» → чат Яндекса +- «Написать письмо» → `https://yandex.ru/support/tooltips-direct/moderation-forms.html` + → оттуда на форму обратной связи `forms.yandex.ru` + +### 7.4. Статусы отказа в разметке + +| Уровень | Метка | Текст | +|---|---|---| +| Объявление, список | `AggregatedStatusContent` | «Модератор отклонил объявление» | +| Объявление, форма | `EntityStatus.StatusLabel.STOP_CRIT`, `NavigationItem.Banner.<номер>.StatusLabel.STOP_CRIT` | «Показы не идут» | +| Группа | `GroupStatusCell` / `AggregatedStatusContent` | «Нет готовых к показу объявлений» | +| Кампания, список | строка кампании | «Нет готовых к показу объявлений · Показы не идут» | + +Пока объявление было на модерации, метка была `…StatusLabel.MODERATION` («На модерации»). +То есть `MODERATION` → `STOP_CRIT` — это переход «вердикт пришёл», но **какой именно +вердикт, ярлык не говорит**: `STOP_CRIT` будет и при отказе, и при остановке по деньгам. +Отличать только по списку объявлений. + +🪤 **Список объявлений повторил свой обман наоборот.** 28.07 днём, пока шла модерация, +`/dna/grid/banners` отдавал «Нет объявлений по заданным параметрам» (§5.4) — фильтр +«Последние 30 дней» при сроке кампании в октябре. После отказа то же объявление в том же +списке с тем же фильтром **появилось**. Механику мы не выясняли; вывод рабочий: +пустой список — не доказательство, что объявления нет, и робот обязан проверять номер +объявления отдельно, а не верить пустоте. + +### 7.5. 🔴 Чего в кабинете НЕ нашлось — и что с этим делать + +Замысел куска 3 предполагал: клиент прикладывает документ в переписке → робот отвозит +его **в кабинет Яндекса** → объявление уходит на повторную модерацию. + +**В кабинете этого пути нет.** В окне отказа ноль полей для файла; документы Яндекс +принимает только снаружи — через чат поддержки или форму обратной связи. + +Оговорка, чтобы не выдать частный случай за общее правило: наш отказ был +«поправьте креатив» (нет предупреждающей надписи), а не «пришлите лицензию». +Не исключено, что у лицензируемых тематик (медицина, алкоголь, банки) окно отказа +выглядит иначе и поле для файла там есть. Проверить это можно только заведя ещё одну +пустышку с такой тематикой и дождавшись второго вердикта — сутки-двое. + +~~Пока не проверено — задача 16 «доставка документа» роботом через кабинет +считается неподтверждённой.~~ ✅ **Проверено 28.07.2026 вечером, см. §7.6: у лицензируемой +тематики окно отказа ровно такое же, поля для файла нет и там.** Дороги «отвезти документ +роботом в кабинет» не существует. Решение, что делать вместо этого, — за владельцем. + +### 7.6. Вторая проба — лицензируемая тематика, заведена 28.07.2026 + +Владелец выбрал проверить. В **той же пустышке** № 713110757, в той же группе +№ 5777621199 заведено второе объявление — **№ 17787102785**, креатив 300×250 +«Стоматология · Имплантация зубов». Медицинские услуги лицензируются, значит модерация +должна потребовать **лицензию**, то есть документ. Ждём вердикт. + +Полный мастер создания кампании для этого не нужен: кампания и группа уже есть, +хватило формы нового объявления (§2) и загрузки креатива (§5.2). + +🔑 **Подтвердилось живьём: сохранение объявления само отправляет его на модерацию.** +Кнопку «Запустить кампанию» не нажимали — после `SaveBannerButton` объявление сразу +получило статус **«Объявление на модерации · Обрабатывается»**. Это ровно то, о чём +Яндекс пишет в окне отказа, и это подтверждает §7.1 п.2 на втором случае. + +🪤 **Переименовывать набор креативов карандашиком не надо.** Клик по +`canvas-editable-label__btn_action_edit-name` оставляет поле в режиме правки, и оно +перехватывает клик по кнопке «Создать» — загрузка встаёт намертво без внятной ошибки. +Имя набору не задаём: список идёт «Сначала новые», берём первый. + +**Вердикт по второй пробе — пришёл 28.07.2026 вечером. Окно отказа ТО ЖЕ САМОЕ.** +Снимок — `docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-medicina.png`. + +Краткая причина (`RejectRow`): **«Нет предупреждений: медицина»**. Подробность +(`MiniGridRow`, после раскрытия), дословно: + +```text +Объявление о лекарственных препаратах, медицинских услугах и медицинском оборудовании +должно содержать предупреждение о наличии противопоказаний, необходимости ознакомиться +с инструкцией по применению или проконсультироваться со специалистом. + +Площадь предупреждения — не менее 5% площади объявления. + +Как пройти модерацию +Добавьте предупреждение, и объявление автоматически отправится на повторную проверку. +``` + +🔴 **Ответ на вопрос, ради которого заводили вторую пробу: поля для документа НЕТ и здесь.** +Список полей окна пуст (`inputs: []`), кнопки те же две — `AggregatedStatusAction` +«Написать в чат» и «Написать письмо», обе наружу из кабинета. Разметка совпала +с первым отказом до метки: `BannerStatusCell.Popup` → `MiniGrid` → `RejectRow` → +`MiniGridRow` → `AggregatedStatusAction`. Ссылки — те же самые. + +⚠️ **Честная оговорка: экрана «пришлите лицензию» мы так и не увидели.** Медицинскую +рекламу Яндекс отклонил снова за **отсутствие предупреждающей надписи**, а не за +отсутствие бумаги. То есть доказано другое, но не менее полезное: **лицензируемая +тематика сама по себе окна с документом не открывает**. Может ли такой экран +существовать при каком-то третьем виде отказа — неизвестно, и гоняться за ним дальше +пустышками смысла мало. + +🔑 **Обоим отказам лечение одно и то же — переделать картинку.** Ни первому, ни второму +никакой документ не помог бы. Это и есть рабочий вывод для замысла. + +### 7.7. 🔴🔴 Главное: программный интерфейс Яндекса причину отказа НЕ отдаёт + +Проверено живьём 28.07.2026 — один запрос на чтение боевым ключом, с разрешения владельца. +`ads.get` по обоим пробным объявлениям вернул: + +```json +{ "Id": 17787055204, "Status": "REJECTED", "StatusClarification": "\nОтклонено на модерации." } +{ "Id": 17787102785, "Status": "MODERATION", "StatusClarification": "Ожидает модерации." } +``` + +**«Отклонено на модерации.» — это весь ответ.** На экране в это же время висит +«Нет предупреждения: финансовые услуги» и абзац пояснения с указанием, что именно +дописать в баннер. Машине Яндекс не говорит ничего. + +Причина не спрятана и в подполях: запросили `CpmBannerAdBuilderAdFieldNames` +со всем, что есть, — `Creative`, `Href`, `TurboPageModeration`. В `Creative` только +номер и картинки предпросмотра, `TurboPageModeration` — `null` (турбо-страницы нет). +По документации `StatusClarification` у медийного объявления вообще встречается только +в модерации турбо-страницы, которой у нас не бывает. + +**Что из этого следует:** + +1. 🔑 **Задача 15 «разведка роботом» — не удобство, а единственный способ узнать причину.** + Раньше это выглядело как «сходить посмотреть на всякий случай». Теперь ясно: + без робота портал знает только *факт* отказа. +2. 🔴 **В готовом куске 1 есть скрытый дефект** — см. `2026-07-28-STATE-okno-peredachi.md`. + Портал кладёт клиенту в переписку «Отклонено на модерации.» как «пояснение Яндекса + целиком», а под ярлыком «Отклонено» в списке кампаний не показывает **ничего**: + текст начинается с переноса строки, а подпись берётся как первая строка. + +🪤 Урок тот же, что с экраном кабинета: **источник, который считался говорящим, +на живых данных оказался немым.** Поймать это тестами было нельзя — тесты подставляют +выдуманную причину, и в них всё выглядит прекрасно. + +### 7.8. 🔴 Живой прогон робота 28.07.2026 — ЗАМЕРЫ, а не догадки + +Разведку впервые прогнали живьём: вызвали **саму функцию робота** `readRejection()` против +боевого кабинета на обоих отклонённых объявлениях пустышки. Прогон вскрыл **две поломки**, +которых не видел ни один из 74 зелёных тестов. Обе — один и тот же класс: **робот СЧИТАЛ +элементы вместо того, чтобы их ЖДАТЬ**. + +Замеры (кабинет `sasha261185`, обычный рабочий канал): + +| Что | Сколько ждать | +|---|---| +| Список объявлений: ячейка `Grid.Cell-<номер>_aggregated-status-info` | появляется на **2-й секунде** после загрузки страницы | +| Окно `BannerStatusCell.Popup` после клика | открывается быстро, но **с одним заголовком** | +| Строка `RejectRow` внутри окна | появляется **позже** окна, в пределах секунды-двух | +| `MiniGridRow` (подробное пояснение) | только **после клика** по `RejectRow` | +| Весь путь разведки целиком | **6–7 секунд** на объявление | + +🪤 **Текст окна НАРАСТАЕТ по частям** — это главное, что надо знать. Замеры одного окна: + +- сразу после открытия — **29 знаков**: один заголовок «Модератор отклонил объявление»; +- когда дорисовалась строка причины — **82 знака**: плюс краткая формулировка; +- после раскрытия строки — **785 знаков**: полное пояснение модератора. + +Читать окно раньше времени не даёт ошибки: оно честно отдаёт то, что успело нарисоваться. +Именно поэтому промах здесь **молчаливый** — робот доложил бы заголовок и считал бы, что +справился. + +🔴 **Правило, выведенное из прогона: в этом кабинете `count()` применять нельзя.** +`count()` отвечает про «прямо сейчас» и не ждёт. Годится только `waitFor` с запасом. +Обе поломки были ровно этим; починка — ожидание вместо счёта, сторожа в `test/inspect.test.js`. + +Проверено после починки: оба объявления отдали причину целиком — 792 знака (финансовые +услуги) и 621 знак (медицина). Снимок берётся **только с окна**, логин и остаток счёта +в кадр не попадают — проверено глазами. + +🪤 **Хвост доклада.** В текст окна входят подписи кнопок «Написать в чат» и «Написать +письмо». Это кнопки НАШЕГО кабинета поставщика, клиенту они бесполезны и выглядят +приглашением написать Яндексу. Решением владельца 28.07.2026 обе строки срезаются +с конца доклада; слова Яндекса про причину не трогаются. + +🪤🪤 **Яндекс пишет через НЕРАЗРЫВНЫЙ пробел (код 160).** Поймано на обрезке хвоста: +«Написать письмо» срезалось, а «Написать в чат» оставалось — между «в» и «чат» стоит +не тот пробел, что кажется глазу. Видно и в соседней строке: «Показы не·идут». +🔑 **Любое сравнение текста из этого кабинета делать по «человеческому» виду строки** +(все пробельные подряд → один обычный), иначе сравнение врёт на ровном месте. + +⚠️ Кнопки `AggregatedStatusAction` появляются в окне **только после раскрытия** строки +причины: до раскрытия их список пуст. Это ещё одно подтверждение, что окно наливается +по частям. + +Строк причины в обоих отказах — по одной. Отказ **с несколькими** причинами живьём +не встречался: раскрывается только первая строка, поведение остальных неизвестно. diff --git a/bots/yandex-creatives/package.json b/bots/yandex-creatives/package.json new file mode 100644 index 00000000..15872c9f --- /dev/null +++ b/bots/yandex-creatives/package.json @@ -0,0 +1,17 @@ +{ + "name": "yandex-creatives-robot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "run:once": "node bin/run.js", + "keepalive": "node bin/keepalive.js", + "login": "node bin/login.js" + }, + "dependencies": { + "playwright": "^1.47.0", + "nodemailer": "^6.9.0", + "dotenv": "^16.4.0" + } +} diff --git a/bots/yandex-creatives/src/browser.js b/bots/yandex-creatives/src/browser.js new file mode 100644 index 00000000..5b69fd10 --- /dev/null +++ b/bots/yandex-creatives/src/browser.js @@ -0,0 +1,22 @@ +import { chromium } from 'playwright'; + +/** + * Постоянный профиль браузера робота: вход в кабинет заводится один раз глазами + * (bin/login.js через удалённый рабочий стол на боевом сервере) и живёт на диске. + * + * Без экрана по умолчанию — на боевом сервере экрана нет; для разового входа + * bin/login.js запускается с экраном внутри виртуального рабочего стола. + */ +export async function openBrowser(config, { headless = true } = {}) { + const context = await chromium.launchPersistentContext(config.profileDir, { + headless, + viewport: { width: 1440, height: 900 }, + locale: 'ru-RU', + ignoreDefaultArgs: ['--enable-automation'], + args: ['--disable-blink-features=AutomationControlled'], + }); + + const page = context.pages()[0] ?? (await context.newPage()); + + return { context, page }; +} diff --git a/bots/yandex-creatives/src/cabinet.js b/bots/yandex-creatives/src/cabinet.js new file mode 100644 index 00000000..b9f61729 --- /dev/null +++ b/bots/yandex-creatives/src/cabinet.js @@ -0,0 +1,307 @@ +import { humanPause } from './human.js'; +import { overviewUrl } from './session.js'; + +/** + * Загрузка готовых картиночных креативов в кабинет Директа. + * + * Конструктор креативов закрыт 01.06.2026, через API картинку не создать — остаётся + * пункт «Загрузить креативы» в меню креатива объявления. Вся последовательность экранов + * и все пометки элементов сняты живьём: docs/cabinet-flow.md. Ничего не выдумываем. + * + * Робот НЕ читает номера созданных креативов — их добывает портал слепком creatives.get + * «до/после». Робот только грузит файлы и говорит «готово». + */ + +// Пометки элементов кабинета — docs/cabinet-flow.md §2. +const FORM = '[data-testid="BannersEditPage"]'; +const OPEN_MENU = '[data-testid="ImageCreativeEditor.OpenMenu"]'; +const MENU_UPLOAD = '[data-testid="ImageCreativeEditor.Menu.add-html5"]'; +const MODAL = '[data-testid="CanvasModal.html5"]'; +/** + * Поле файлов — то, что ВНУТРИ окна загрузки, у площадки перетаскивания. Своей пометки + * у него нет, поэтому отбираем по принадлежности окну. + * + * 🔴 Не путать с полем формы `CreativeActionsMenu.FileInput`: у него и пометка есть, + * и список разрешённых типов — но Яндекс положенные туда файлы НЕ принимает, кнопка + * «Создать» остаётся серой. Разметку 27.07 снимали глазами, ничего не загружая, и поле + * выбрали по виду — ошибка. Живая загрузка 28.07 показала правду: cabinet-flow.md §0, §5.2. + */ +const FILE_INPUT = `${MODAL} input[type=file]`; +const SUBMIT = '[data-testid="CanvasFooter.SubmitButton"]'; +const MODAL_CLOSE = '[data-testid="CanvasModal.html5.CloseButton"]'; + +/** + * Адрес «прихожей» — форма НОВОГО объявления существующей группы. + * + * Почему новое, а не существующее: пока не нажата «Сохранить изменения», в кабинете + * не появляется ничего. Форма нужна роботу только как проход к окну загрузки. Лезть в + * живое объявление работающей кампании нельзя — там можно испортить рекламу за деньги. + */ +export function anteroomUrl(config) { + const params = new URLSearchParams({ + ulogin: config.cabinetLogin, + 'campaigns-ids': String(config.anteroomCampaignId), + 'groups-ids': String(config.anteroomGroupId), + 'is-new': '1', + }); + + return `${config.cabinetUrl}/dna/banners-edit?${params.toString()}`; +} + +export async function uploadCreatives(page, config, files, options = {}) { + const { + formTimeoutMs = 20000, + menuTimeoutMs = 5000, + modalTimeoutMs = 10000, + acceptTimeoutMs = 60000, + // Живьём окно не закрывается вовсе — держать робота две минуты впустую незачем. + modalCloseTimeoutMs = 20000, + pollMs = 250, + } = options; + + if (!Array.isArray(files) || files.length === 0) { + throw new Error('Нечего грузить: список файлов пуст.'); + } + + // Шаг 1 — форма нового объявления. Догружается отдельно от каркаса страницы, ждём дольше. + await page.goto(anteroomUrl(config), { waitUntil: 'domcontentloaded' }); + await page.locator(FORM).waitFor({ timeout: formTimeoutMs }); + await humanPause(config); + + // Шаг 2 — меню креатива. + await page.locator(OPEN_MENU).click(); + await page.locator(MENU_UPLOAD).waitFor({ timeout: menuTimeoutMs }); + await humanPause(config); + + // Шаг 3 — «Загрузить креативы» открывает окно загрузки. + await page.locator(MENU_UPLOAD).click(); + await page.locator(MODAL).waitFor({ timeout: modalTimeoutMs }); + await humanPause(config); + + // Шаг 4 — кладём файлы прямо в поле. Системное окно «Выберите файл» не открывается. + // Грузим всей пачкой: имя креатива в кабинете одно на всю загрузку, и опознание всё + // равно идёт по размеру из слепка, а не по имени. + await page.locator(FILE_INPUT).setInputFiles(files); + + // Шаг 5 — «Создать» серая, пока файлы не приняты. Её разблокировка и есть признак + // «кабинет файлы взял»; другого честного признака у нас нет. + const submit = page.locator(SUBMIT); + await waitUntilEnabled(submit, acceptTimeoutMs, pollMs); + await humanPause(config); + await submit.click(); + + // Шаг 6 — окно после «Создать». + // + // 🔑 Снято живьём 28.07.2026: окно НЕ закрывается — оно переключается на вкладку + // «Мои креативы». Поэтому «окно не закрылось» это НОРМА, а не беда. Раньше робот на + // каждой удачной загрузке звал человека письмом-алярмом; письмо, которое приходит + // всегда, перестают читать вовсе. + // + // 🔴 Признак «набор создан» с экрана мы НЕ читаем. Подтверждённой метки у нас нет: + // список наборов в разметке — скрытая галочка `BatchesList`, по видимости её проверять + // нельзя, а гадать по экрану — ровно тот способ, которым уже дважды за этот день + // получалась мёртвая программа при зелёных тестах. + // + // Правда об успехе живёт не на экране, а в слепке `creatives.get`: портал сверяет + // «до/после» и, если креатив не появился, закрывает задание сбоем с внятной причиной — + // она видна в админке «ждёт разбора». Этого сторожа достаточно, экранный не нужен. + // + // Ждём закрытия недолго: обычно его не будет вовсе, и держать робота две минуты + // впустую незачем. + let modalClosed = true; + let cabinetSaid = ''; + + try { + await page.locator(MODAL).first().waitFor({ state: 'detached', timeout: modalCloseTimeoutMs }); + } catch { + modalClosed = false; + // Слова кабинета уносим в отчёт — по ним разбирают сбой, если он всё-таки случился. + cabinetSaid = (await page.locator(MODAL).first().innerText().catch(() => '')).trim(); + // Закрываем крестиком — так предписывает разметка, §2 шаг 6. + await page.locator(MODAL_CLOSE).first().click().catch(() => {}); + } + + // Шаг 7 — уходим со страницы. Кнопку «Сохранить изменения» (SaveBannerButton) не + // трогаем НИКОГДА: она единственная на этом пути меняет кабинет. Черновик объявления + // никуда не сохраняется. + // + // Осечку тут глотаем намеренно: работа уже сделана — файлы приняты, «Создать» нажата. + // Сетевая икота на уборке за собой не должна выдаваться за «креативы не загрузились», + // иначе портал пометит задание сбойным, а креативы останутся лежать в кабинете. + await page.goto(overviewUrl(config), { waitUntil: 'domcontentloaded' }).catch(() => {}); + + return { modalClosed, cabinetSaid }; +} + +// ── Разведка: прочитать, за что Яндекс отклонил объявление ──────────────────────────── +// +// 🔑 Зачем это вообще. Программный интерфейс Яндекса причину отказа НЕ отдаёт: на +// отклонённое объявление приходит «Отклонено на модерации.» и всё (проверено живьём +// боевым ключом 28.07.2026). Настоящая причина висит только на экране кабинета. +// Разметка — docs/cabinet-flow.md §7, снята по натуре на двух отказах подряд. +// +// 🔴 Разведка НИЧЕГО в кабинете не меняет: только открывает список и раскрывает окно. +const STATUS_CONTENT = '[data-testid="AggregatedStatusContent"]'; +const POPUP = '[data-testid="BannerStatusCell.Popup"]'; +const REJECT_ROW = '[data-testid="RejectRow"]'; +// Подробное пояснение модератора. Живёт на слой глубже и появляется ТОЛЬКО после раскрытия +// строки причины — по нему и понимаем, что раскрытие сработало. +const MINI_GRID_ROW = '[data-testid="MiniGridRow"]'; + +/** + * Подписи кнопок, которые попадают в текст окна вместе с причиной. Сняты живьём + * 28.07.2026: обе ведут наружу из НАШЕГО кабинета поставщика, у клиента их нет и быть + * не может. В докладе они выглядят приглашением написать Яндексу — то есть обманывают. + */ +const KNOPKI_KABINETA = ['Написать в чат', 'Написать письмо']; + +/** + * Срезает с КОНЦА доклада подписи кнопок кабинета. Решение владельца 28.07.2026. + * + * 🔴 Только хвост и только точное совпадение строки: те же слова внутри пояснения — + * это слова Яндекса, и трогать их нельзя. Причина едет клиенту слово в слово. + * + * 🪤 Сравниваем по «человеческому» виду строки: Яндекс ставит между короткими словами + * НЕРАЗРЫВНЫЙ пробел (код 160). Глазу он неотличим от обычного, а точному сравнению — + * совсем другой символ. Поймано живым прогоном: «Написать письмо» срезалось, а + * «Написать в чат» оставалось именно из-за него. + * + * 🪤 Переименует Яндекс кнопки — две лишние строки вернутся в доклад. Это некрасиво, + * но безопасно: причина не пострадает. Гоняться за подписями не стоит. + */ +export function bezKnopokKabineta(report) { + const почеловечески = (s) => s.replace(/\s+/g, ' ').trim(); + const кнопки = KNOPKI_KABINETA.map(почеловечески); + const строки = report.split('\n'); + + while (строки.length > 0) { + const последняя = почеловечески(строки[строки.length - 1]); + if (последняя !== '' && !кнопки.includes(последняя)) { + break; + } + строки.pop(); + } + + return строки.join('\n'); +} + +/** + * Адрес СПИСКА объявлений кампании. + * + * 🔴 Именно список, а не форма объявления. Форма знает только «Показы не идут» + * (`EntityStatus.StatusLabel.STOP_CRIT`) и про модерацию не говорит ни слова — проверено + * живьём. Заодно на форме живут кнопки, которые меняют кабинет, и робота там быть не должно. + * + * `status-filter=ALL` обязателен: по умолчанию список стоит на «Последние 30 дней» и на + * кампанию со сроком в будущем отдаёт «Нет объявлений по заданным параметрам». + */ +export function rejectionUrl(config, job) { + const params = new URLSearchParams({ + ulogin: config.cabinetLogin, + 'campaigns-ids': String(job.yandex_campaign_id), + 'status-filter': 'ALL', + }); + + return `${config.cabinetUrl}/dna/grid/banners?${params.toString()}`; +} + +/** + * Читает причину отказа по конкретному объявлению и снимает окно. + * + * Возвращает `{ report, screenshotPath }`. Ничего не понял — бросает ошибку: пусть задание + * закроется сбойным и человек посмотрит сам. Выдумывать причину нельзя ни в каком виде — + * по выдуманной клиент пойдёт переделывать не то. + */ +export async function readRejection(page, config, job, screenshotPath, options = {}) { + const { popupTimeoutMs = 20000, cellTimeoutMs = 30000, rowTimeoutMs = 20000 } = options; + + const adId = String(job.yandex_ad_id); + // Ячейка статуса ИМЕННО нашего объявления. 🪤 Брать «первую строку с отказом» нельзя: + // список объявлений врёт в обе стороны (во время модерации отдавал «Нет объявлений», + // после отказа то же объявление показал), а в кампании объявление на каждый размер блока. + // Ошибись строкой — и клиент получит причину чужого объявления как свою. + const cellSelector = `[data-testid="Grid.Cell-${adId}_aggregated-status-info"]`; + + await page.goto(rejectionUrl(config, job), { waitUntil: 'domcontentloaded' }); + await humanPause(config); + + // 🔴 Ячейку надо ЖДАТЬ, а не считать. Проверено живым прогоном 28.07.2026: кабинет рисует + // список примерно за 2 секунды, а `count()` не ждёт — отвечает тем, что на экране прямо + // сейчас. Робот считал сразу после человекоподобной паузы (по умолчанию 800 мс) и получал + // честный ноль: «объявления нет». На бою это значило бы, что КАЖДЫЙ отказ уезжает + // в «ждёт разбора», а клиент причину не узнаёт никогда. + // + // Отличить «ещё не отрисовалось» от «объявления правда нет» умеет только ожидание — + // ровно так же, как ждут все остальные шаги этого файла. + const cell = page.locator(cellSelector); + try { + await cell.first().waitFor({ timeout: cellTimeoutMs }); + } catch { + throw new Error(`Объявления ${adId} в списке кабинета нет — причину прочитать не у чего`); + } + + // 🪤 Окно открывается КЛИКОМ. Наведение мышью не работает — проверено живьём. + // + // Везде `.first()`: если на странице почему-то окажется два таких элемента, Playwright + // в строгом режиме бросит ошибку про «несколько совпадений», и робот сдастся не потому, + // что кабинет поменялся, а потому, что мы неаккуратно выбрали элемент. + await cell.locator(STATUS_CONTENT).first().click(); + await page.locator(POPUP).first().waitFor({ timeout: popupTimeoutMs }); + await humanPause(config); + + // 🔑 Строку причины надо РАСКРЫТЬ: до этого виден только короткий заголовок + // («Нет предупреждения: финансовые услуги»), а всё пояснение спрятано на слой глубже. + // + // 🔴 Строку тоже ЖДЁМ. Живой прогон 28.07.2026: окно открывается РАНЬШЕ, чем в нём + // появляется строка причины. Робот считал строки сразу после паузы, получал ноль, + // раскрывать было нечего — и уносил клиенту один заголовок «Модератор отклонил + // объявление»: 29 знаков вместо 785. Клиент узнал бы ровно то, что и так знает. + const row = page.locator(REJECT_ROW); + try { + await row.first().waitFor({ timeout: rowTimeoutMs }); + } catch { + throw new Error(`В окне по объявлению ${adId} нет строки причины — докладывать нечего`); + } + + await row.first().click(); + + // Раскрытие подтверждаем ПОЯВЛЕНИЕМ подробности, а не паузой: пауза — это надежда, + // а `MiniGridRow` — факт. Не дождались — не беда: короткая формулировка уже в окне + // и клиенту полезна. Промолчать из-за недостающей подробности хуже, чем сказать коротко. + try { + await page.locator(MINI_GRID_ROW).first().waitFor({ timeout: rowTimeoutMs }); + } catch { + // остаёмся с короткой причиной — она честная, просто без пояснения + } + + await humanPause(config); + + const report = bezKnopokKabineta((await page.locator(POPUP).first().innerText()).trim()); + if (report === '') { + throw new Error(`Окно причины по объявлению ${adId} пустое — докладывать нечего`); + } + + // Снимок — дело хорошее, но не обязательное: причина важнее картинки. Снимаем ОДНО окно, + // а не страницу целиком: на полном снимке видна боковая панель с логином и остатком счёта. + let shot = screenshotPath; + try { + await page.locator(POPUP).first().screenshot({ path: screenshotPath }); + } catch { + shot = null; + } + + return { report, screenshotPath: shot }; +} + +async function waitUntilEnabled(locator, timeoutMs, pollMs) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (!(await locator.isDisabled())) { + return; + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + + throw new Error('Кабинет не принял файлы: кнопка «Создать» так и осталась серой.'); +} diff --git a/bots/yandex-creatives/src/config.js b/bots/yandex-creatives/src/config.js new file mode 100644 index 00000000..3896642c --- /dev/null +++ b/bots/yandex-creatives/src/config.js @@ -0,0 +1,54 @@ +function required(env, key) { + const v = env[key]; + if (v === undefined || v === '') throw new Error(`Не задана переменная окружения: ${key}`); + return v; +} + +/** + * Число из окружения. `Number(env.X ?? '800')` ловил только отсутствие переменной: + * пустое значение в .env (`HUMAN_DELAY_MS=`) давало Number('') === 0, и робот начинал + * щёлкать по кабинету с машинной скоростью — ровно так антифрод Яндекса и опознаёт бота. + * Мусор в значении молча становился NaN: пауза «никакая», а порт почты NaN — письма + * переставали уходить без единого внятного слова в журнале. + */ +function number(env, key, fallback) { + const raw = env[key]; + if (raw === undefined || String(raw).trim() === '') return fallback; + + const value = Number(raw); + if (!Number.isFinite(value)) throw new Error(`Переменная окружения ${key} должна быть числом, а там: ${raw}`); + + return value; +} + +export function loadConfig(env = process.env) { + return { + profileDir: required(env, 'YC_BROWSER_PROFILE_DIR'), + cabinetUrl: required(env, 'YC_CABINET_URL').replace(/\/+$/, ''), + // Логин кабинета: в адресах Директа он идёт параметром ulogin. + cabinetLogin: required(env, 'YC_CABINET_LOGIN'), + // «Прихожая» — уже существующие кампания и группа, форму НОВОГО объявления которых + // робот открывает только чтобы добраться до окна загрузки креативов. Своей кампании + // у него в этот момент ещё нет: портал ставит задание ДО создания кампании в Яндексе, + // потому что кампания заводится сразу с готовыми номерами креативов. Ничего в + // «прихожей» не сохраняется — кнопку «Сохранить изменения» робот не нажимает никогда. + anteroomCampaignId: required(env, 'YC_ANTEROOM_CAMPAIGN_ID'), + anteroomGroupId: required(env, 'YC_ANTEROOM_GROUP_ID'), + // Портал, у которого робот спрашивает работу и которому отчитывается. + portalBaseUrl: required(env, 'PORTAL_BASE_URL').replace(/\/+$/, ''), + robotToken: required(env, 'CREATIVE_ROBOT_TOKEN'), + smtp: { + host: required(env, 'SMTP_HOST'), + port: number({ SMTP_PORT: required(env, 'SMTP_PORT') }, 'SMTP_PORT', 0), + user: required(env, 'SMTP_USER'), + pass: required(env, 'SMTP_PASS'), + }, + alarmFrom: required(env, 'ALARM_FROM'), + alarmTo: required(env, 'ALARM_TO'), + // Человекоподобный темп: кабинет не должен видеть машинную скорость. + humanDelayMs: number(env, 'HUMAN_DELAY_MS', 800), + // Пауза между повторами доклада порталу «готово». Доклад повторяется, потому что + // обрыв на нём стоит дорого: креативы уже в кабинете, а портал об этом не знает. + reportRetryDelayMs: number(env, 'REPORT_RETRY_DELAY_MS', 3000), + }; +} diff --git a/bots/yandex-creatives/src/env.js b/bots/yandex-creatives/src/env.js new file mode 100644 index 00000000..348489e2 --- /dev/null +++ b/bots/yandex-creatives/src/env.js @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + +/** + * Читает `.env` РОБОТА, а не того каталога, откуда его запустили. + * + * `import 'dotenv/config'` ищет файл от текущего каталога процесса. Робота запускает + * расписание, и каталог запуска у него может быть любым: настройки просто не нашлись бы, + * а `loadConfig()` упал бы «не задана переменная окружения» — притом что файл лежит + * на месте. Папки для картинок и снимков от каталога запуска отвязаны давно (runner.js), + * настройки оставались последним местом с этой миной. + */ +export function loadEnvFile() { + dotenv.config({ path: fileURLToPath(new URL('../.env', import.meta.url)) }); +} diff --git a/bots/yandex-creatives/src/human.js b/bots/yandex-creatives/src/human.js new file mode 100644 index 00000000..3807eb00 --- /dev/null +++ b/bots/yandex-creatives/src/human.js @@ -0,0 +1,9 @@ +/** + * Человекоподобный темп между действиями: кабинет не должен видеть машинную скорость. + * + * Живёт отдельным файлом, а не в browser.js, нарочно: browser.js тянет за собой + * Playwright, а пауза нужна и тем частям, которые проверяются без браузера. + */ +export function humanPause(config) { + return new Promise((resolve) => setTimeout(resolve, config.humanDelayMs ?? 0)); +} diff --git a/bots/yandex-creatives/src/lock.js b/bots/yandex-creatives/src/lock.js new file mode 100644 index 00000000..66937407 --- /dev/null +++ b/bots/yandex-creatives/src/lock.js @@ -0,0 +1,80 @@ +import { closeSync, openSync, readFileSync, rmSync, writeSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** Сколько живёт замок, брошенный убитым процессом. */ +const STALE_MS = 30 * 60 * 1000; + +/** + * Путь к общему замку робота. Считается от корня робота, а не от каталога запуска: + * иначе два запуска из разных мест взяли бы РАЗНЫЕ замки и спокойно подрались бы + * за один и тот же профиль браузера. + */ +export function robotLockPath() { + return fileURLToPath(new URL('../robot.lock', import.meta.url)); +} + +/** + * Файл-замок «работает только один робот». + * + * Зачем. Chromium держит папку профиля под своим замком, и второй процесс просто не + * стартует. Раньше это падало ВНУТРИ рабочего блока — и задание помечалось сбойным, хотя + * ничего не сломано: расписание всего лишь запустило проход поверх ещё не закончившегося + * (README предписывает гонять проход часто, а поддержание входа — раз в ~15 минут). + * Правильное поведение при занятом замке — молча уйти и попробовать в следующий раз. + * + * Обратная сторона: процесс могли убить насмерть (перезагрузка сервера), и снять замок + * было бы некому — робот не работал бы уже никогда. Поэтому замок старше STALE_MS + * считается брошенным и перехватывается. Нечитаемое содержимое (обрыв записи, потрогали + * руками) — тоже: неизвестность не должна запирать робота навсегда. + * + * Атомарность даёт сама файловая система: флаг 'wx' создаёт файл ТОЛЬКО если его нет. + */ +export function acquireLock(path, { staleMs = STALE_MS, pid = process.pid } = {}) { + const taken = tryCreate(path, pid); + if (taken !== null) { + return taken; + } + + if (!isStale(path, staleMs)) { + return { ok: false, release() {} }; + } + + try { rmSync(path, { force: true }); } catch { /* уже убрали */ } + + return tryCreate(path, pid) ?? { ok: false, release() {} }; +} + +/** Возвращает взятый замок либо null, если файл уже существует. */ +function tryCreate(path, pid) { + let fd; + try { + fd = openSync(path, 'wx'); + } catch { + return null; + } + + try { + writeSync(fd, JSON.stringify({ pid, at: Date.now() })); + } finally { + closeSync(fd); + } + + return { + ok: true, + release() { + try { rmSync(path, { force: true }); } catch { /* уже нет */ } + }, + }; +} + +function isStale(path, staleMs) { + try { + const held = JSON.parse(readFileSync(path, 'utf8')); + const at = Number(held?.at); + if (!Number.isFinite(at)) return true; + + return Date.now() - at > staleMs; + } catch { + return true; + } +} diff --git a/bots/yandex-creatives/src/mailer.js b/bots/yandex-creatives/src/mailer.js new file mode 100644 index 00000000..86b38ebb --- /dev/null +++ b/bots/yandex-creatives/src/mailer.js @@ -0,0 +1,56 @@ +/** + * Письма человеку. Два повода: «робот встал» и «робот сделал». + * + * Транспорт передаётся снаружи — так письма проверяются без настоящей почты. Настоящий + * транспорт живёт в отдельном файле src/smtp.js: он тянет nodemailer, а зависимости стоят + * только на боевом сервере, и пока импорт был здесь, тексты писем нельзя было проверить + * ни одним тестом. + */ +export function createMailer(transport, { from, to }) { + return { + /** + * `uploaded` — были ли креативы к этому моменту УЖЕ залиты в кабинет. + * + * Раньше письмо было одно на все случаи и всегда утверждало «робот остановился и ничего + * в кабинете не менял». Тем же письмом сообщали, например, «окно загрузки не закрылось» — + * а там файлы уже приняты и робот уже отчитался «готово». Человек читал, что ничего + * не произошло, и в кабинет не шёл. А идти надо: там лежат креативы, и повторная заливка + * оставит дубли, которые вычищаются только руками. + */ + async alarm({ step, reason, campaignId, screenshotPath, uploaded = false }) { + const tail = uploaded + ? [ + '🔴 Креативы В КАБИНЕТ УЖЕ ЗАГРУЖЕНЫ — робот дошёл до конца работы.', + 'Загляните в кабинет и в очередь заданий: повторная заливка оставит дубли,', + 'вычистить их можно только руками.', + ] + : [ + 'Робот остановился и ничего в кабинете не менял.', + 'Кампания осталась черновиком, деньги не потрачены.', + ]; + + await transport.sendMail({ + from, + to, + subject: `[Робот креативов] АЛЯРМ на шаге «${step}»`, + text: [ + `Кампания: ${campaignId}`, + `Шаг: ${step}`, + `Причина: ${reason}`, + '', + ...tail, + ].join('\n'), + attachments: screenshotPath ? [{ path: screenshotPath }] : [], + }); + }, + + async report({ campaignId, count }) { + await transport.sendMail({ + from, + to, + subject: '[Робот креативов] Готово', + text: `Креативы загружены.\nКампания: ${campaignId}\nФайлов: ${count}`, + }); + }, + }; +} diff --git a/bots/yandex-creatives/src/portal.js b/bots/yandex-creatives/src/portal.js new file mode 100644 index 00000000..3dd913ca --- /dev/null +++ b/bots/yandex-creatives/src/portal.js @@ -0,0 +1,146 @@ +import { createWriteStream, openAsBlob } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { basename, dirname } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +/** + * Разговор робота с порталом. fetch передаётся снаружи — так его можно подменить в тестах. + */ +/** + * Портал принимает причину сбоя не длиннее 1024 знаков. Берём с запасом: сообщения + * Playwright при таймауте штатно тянут за собой «Call log:» на десятки строк, и отчёт + * о САМОМ частом виде сбоя портал отверг бы целиком. Отчёт не принят → задание навсегда + * «в работе» → очередь встаёт колом для всех клиентов. + */ +const REASON_LIMIT = 900; + +function shortenReason(reason) { + const text = String(reason ?? ''); + + return text.length <= REASON_LIMIT ? text : `${text.slice(0, REASON_LIMIT - 1)}…`; +} + +/** + * Доклад разведки портал принимает до 20 000 знаков — это слова самого Яндекса, и режем + * мы их с большим запасом: обычное пояснение модератора укладывается в пару абзацев. + * Обрезанная причина всё равно полезнее отвергнутого целиком доклада. + */ +const REPORT_LIMIT = 20000; + +function shortenReport(report) { + const text = String(report ?? ''); + + return text.length <= REPORT_LIMIT ? text : `${text.slice(0, REPORT_LIMIT - 1)}…`; +} + +/** + * Настоящее расширение файла — по типу содержимого, который назвал портал. Клиенту + * разрешены jpg, png и gif; раньше робот сохранял любой файл как «.jpg» и таким же + * скармливал его кабинету Яндекса — PNG приезжал туда под чужим именем. Тип не назван + * или незнаком — считаем jpg: это самый частый формат баннера, и хуже прежнего не будет. + */ +function extensionOf(res) { + const type = String(res.headers?.get?.('Content-Type') ?? '').toLowerCase(); + + if (type.includes('png')) return 'png'; + if (type.includes('gif')) return 'gif'; + + return 'jpg'; +} + +/** + * Адрес файла приходит В ОТВЕТЕ сервера, а идём мы по нему СО СВОИМ ТОКЕНОМ. Значит адрес + * обязан вести на портал и никуда больше: подменённый или просто перепутанный адрес увёз бы + * ключ от служебного канала на чужую машину. Робот живёт на боевом сервере и ходит по + * внутренней сети, поэтому цена ошибки здесь выше обычной. + */ +function assertPortalUrl(url, portalBaseUrl) { + let target; + try { + target = new URL(String(url)); + } catch { + throw new Error(`Портал прислал непонятный адрес файла: ${url}`); + } + + const portal = new URL(portalBaseUrl); + if (target.origin !== portal.origin) { + throw new Error(`Адрес файла ведёт не на портал, а на ${target.origin} — за файлом не идём`); + } + + return target.href; +} + +export function createPortal(config, fetchImpl = fetch) { + const headers = { 'X-Creative-Robot-Token': config.robotToken, Accept: 'application/json' }; + + async function call(path, init = {}) { + const res = await fetchImpl(`${config.portalBaseUrl}${path}`, { ...init, headers: { ...headers, ...(init.headers ?? {}) } }); + if (!res.ok) throw new Error(`Портал ответил ${res.status} на ${path}`); + return res.json(); + } + + return { + /** Забрать одно задание. null — работы нет. */ + async takeJob() { + const data = await call('/api/creative-robot/next'); + return data.job ?? null; + }, + + /** + * Скачать файл баннера на диск. `targetBase` — путь БЕЗ расширения: настоящее + * расширение известно только после ответа портала, из типа содержимого. + * Возвращает путь, по которому файл на самом деле лёг. + */ + async downloadBanner(banner, targetBase) { + const fileUrl = assertPortalUrl(banner.file_url, config.portalBaseUrl); + const res = await fetchImpl(fileUrl, { headers }); + if (!res.ok) throw new Error(`Не скачался файл баннера ${banner.width}x${banner.height}: ${res.status}`); + + const targetPath = `${targetBase}.${extensionOf(res)}`; + await mkdir(dirname(targetPath), { recursive: true }); + await pipeline(Readable.fromWeb(res.body), createWriteStream(targetPath)); + + return targetPath; + }, + + /** + * Доклад разведки: что робот прочитал на экране кабинета про отклонённое объявление, + * плюс снимок этого окна. Портал кладёт текст клиенту в ленту слово в слово. + * + * Формой, а не JSON: снимок — файл. 🔴 Заголовок `Content-Type` руками НЕ ставим — + * границу частей формы дописывает сам fetch, а подменённый заголовок портал разберёт + * как пустую форму. + */ + async reportInspection(jobId, report, screenshotPath) { + const form = new FormData(); + form.append('report', shortenReport(report)); + + if (screenshotPath) { + // Снимок — дело хорошее, но не обязательное: причина важнее картинки. Пропал файл, + // не сделался снимок — доклад всё равно обязан доехать до клиента. + try { + form.append('screenshot', await openAsBlob(screenshotPath), basename(screenshotPath)); + } catch { /* снимка нет — едем без него */ } + } + + return call(`/api/creative-robot/jobs/${jobId}/inspection`, { method: 'POST', body: form }); + }, + + async reportDone(jobId) { + return call(`/api/creative-robot/jobs/${jobId}/done`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ok: true }), + }); + }, + + async reportFailure(jobId, reason) { + return call(`/api/creative-robot/jobs/${jobId}/done`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ok: false, reason: shortenReason(reason) }), + }); + }, + }; +} diff --git a/bots/yandex-creatives/src/runner.js b/bots/yandex-creatives/src/runner.js new file mode 100644 index 00000000..06cc684c --- /dev/null +++ b/bots/yandex-creatives/src/runner.js @@ -0,0 +1,219 @@ +import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Корень робота. Папки для картинок и снимков экрана считаем ОТ НЕГО, а не от текущего +// каталога: робота запускает расписание, и каталог запуска у него может быть любым. +const ROBOT_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Сколько держим снимки экрана. Свежие нужны — по ним разбирают вчерашний сбой. Старые + * не нужны никому, а лежать им опасно: на снимке видна боковая панель кабинета с логином + * и остатком счёта. Раньше папка не чистилась вовсе и росла бесконечно. + */ +const SCREENSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1000; + +/** Уборка старых снимков. Любая беда здесь молчит: из-за уборки работа встать не должна. */ +function pruneScreenshots(shotsDir, now) { + let names; + try { + names = readdirSync(shotsDir); + } catch { + return; + } + + for (const name of names) { + const path = join(shotsDir, name); + try { + if (now - statSync(path).mtimeMs > SCREENSHOT_TTL_MS) { + rmSync(path, { force: true }); + } + } catch { /* пропал сам или занят — не наша забота */ } + } +} + +/** + * Один проход робота: спросить работу → скачать файлы → залить в кабинет → отчитаться. + * + * Портал, браузер и почта передаются снаружи — так проход проверяется без настоящего + * браузера и без сети. + * + * Робот НИКОГДА не тычет вслепую: любой сбой = отчёт «сбой» + письмо со снимком экрана, + * кампания остаётся черновиком, деньги не тратятся. + */ +export async function runOnce(config, portal, browser, mailer, { timestamp, workDir = ROBOT_ROOT }) { + const job = await portal.takeJob(); + if (job === null) { + return { idle: true }; + } + + const dir = join(workDir, 'downloads', `job-${job.id}`); + const shotsDir = join(workDir, 'screenshots'); + const screenshot = join(shotsDir, `job-${job.id}-${timestamp}.png`); + let context; + let page; + let step = 'начало'; + let files = []; + let said = null; + let inspection = null; + + // Разведка — «сходить посмотреть», а не «отвезти картинки»: другой путь по кабинету, + // другой доклад порталу и другое понятие удачи. + const isInspection = job.kind === 'inspect'; + + try { + mkdirSync(shotsDir, { recursive: true }); + pruneScreenshots(shotsDir, Date.now()); + mkdirSync(dir, { recursive: true }); + + step = 'скачивание файлов'; + // Разведке картинки клиента не нужны: она ничего не возит, только смотрит. + for (const banner of (isInspection ? [] : job.banners)) { + // Имя начинается с номера баннера: он уникален, размер — нет. Расширение здесь НЕ + // задаём: настоящее известно только из ответа портала, и дописывает его сам portal. + // Раньше всё сохранялось как «.jpg», и PNG уезжал в кабинет Яндекса под чужим именем. + files.push(await portal.downloadBanner(banner, join(dir, `${banner.banner_id}-${banner.width}x${banner.height}`))); + } + + step = 'запуск браузера'; + ({ context, page } = await browser.open(config, {})); + + step = 'проверка входа'; + if (!(await browser.isLoggedIn(page, config))) { + throw new Error('Вход в кабинет слетел — нужен повторный вход глазами'); + } + + if (isInspection) { + step = 'разведка'; + // 🔴 Здесь робот только СМОТРИТ. Ничего не нажимает из того, что меняет кабинет, + // и на форму объявления не заходит вовсе — сторож на это стоит в cabinet.js. + // Не понял, что видит, — бросает ошибку, и мы уходим в общий обработчик сбоя ниже: + // задание закрывается сбойным, владельцу письмо, клиенту в ленту НИЧЕГО. + inspection = await browser.readRejection(page, config, job, screenshot); + } else { + step = 'загрузка креативов'; + // В кабинет уходят ПУТИ к файлам: их кладут прямо в поле файлов на странице. + // + // 🔴 На этом работа робота заканчивается. Доклад «готово» вынесен НИЖЕ, за пределы + // этого try: его обработчик шлёт порталу «сбой», и обрыв на самом докладе объявлял + // бы провалом СВОЮ УДАЧНУЮ работу — задание умирало бы, а креативы оставались бы + // в кабинете. А если портал успел принять «готово» и потерялся только ответ, «сбой» + // затирал бы правильный результат с уже проставленными номерами креативов. + said = await browser.uploadCreatives(page, config, files); + } + } catch (e) { + try { await page?.screenshot({ path: screenshot, fullPage: true }); } catch { /* снимок — не обязателен */ } + + // Отчёт порталу — ПЕРВЫМ: даже если письмо не уйдёт, задание не зависнет «в работе» + // и очередь не встанет колом. + // + // Провал самого отчёта раньше глотался молча — а это худший из исходов: задание + // остаётся «в работе» навсегда, выдача заданий отвечает «работы нет» ВСЕМ клиентам, + // и человек об этом не узнаёт. Теперь причина провала едет в письмо-алярм. + let reportError = null; + try { + await portal.reportFailure(job.id, `${step}: ${e.message}`); + } catch (re) { + reportError = re; + } + + try { + await mailer.alarm({ + step, + reason: reportError === null + ? e.message + : `${e.message}\n\nПорталу доложить не смог: ${reportError.message}. ` + + `Задание #${job.id} могло остаться «в работе» — проверьте очередь заданий.`, + campaignId: job.campaign_id, + screenshotPath: existsSync(screenshot) ? screenshot : null, + }); + } catch { /* почта недоступна */ } + + return { ok: false, jobId: job.id, step, reason: e.message }; + } finally { + // Картинки клиента — временные. Чистим при любом исходе: это чужие материалы, + // копиться на сервере им нельзя. + try { rmSync(dir, { recursive: true, force: true }); } catch { /* уже нет */ } + try { await context?.close(); } catch { /* уже закрыт */ } + } + + // Сюда попадаем ТОЛЬКО когда работа в кабинете уже сделана. Дальше — один лишь доклад. + const reportError = await reportWithRetries( + isInspection + ? () => portal.reportInspection(job.id, inspection.report, inspection.screenshotPath) + : () => portal.reportDone(job.id), + config, + ); + + if (reportError !== null) { + // 🔴 «Сбой» тут не докладываем НИКОГДА: работа сделана, врать о ней нельзя. Зовём + // человека письмом — пусть посмотрит кабинет и очередь заданий руками. + try { + await mailer.alarm({ + step: 'доклад порталу', + reason: (isInspection + ? 'Причину отказа прочитали в кабинете, но доложить порталу не удалось' + : 'Креативы загружены в кабинет, но доложить об этом порталу не удалось') + + `: ${reportError.message}. Задание #${job.id} могло остаться «в работе».`, + campaignId: job.campaign_id, + screenshotPath: null, + uploaded: ! isInspection, + }); + } catch { /* почта недоступна */ } + + return { ok: true, jobId: job.id, count: files.length, reportedToPortal: false }; + } + + if (! isInspection) { + // Письмо-отчёт о заливке. У разведки своего письма нет: доклад ушёл клиенту в ленту, + // а владельца по обычным отказам не беспокоим — только когда робот растерялся. + try { + await mailer.report({ campaignId: job.campaign_id, count: files.length }); + } catch { /* письмо — не повод считать работу проваленной */ } + } + + // 🔴 По виду окна загрузки человека НЕ зовём вовсе. + // + // Раньше письмо-алярм уходило на каждое «окно не закрылось» — а живьём оно и не + // закрывается, оно переключается на вкладку «Мои креативы» (снято 28.07.2026). То есть + // письмо приходило на КАЖДОЙ удачной загрузке, а письмо, которое приходит всегда, + // перестают читать вовсе. + // + // Настоящего сбоя это не прячет: правда об успехе живёт в слепке `creatives.get`. + // Не появился креатив — портал закрывает задание сбоем с внятной причиной, и оно видно + // в админке «ждёт разбора». Слова кабинета отдаём наружу в результате прохода — они + // попадают в журнал робота, если разбирать всё-таки придётся. + return { + ok: true, + jobId: job.id, + count: files.length, + reportedToPortal: true, + cabinetSaid: said?.cabinetSaid || '', + }; +} + +/** + * Доклад порталу с повторами. Возвращает null при успехе либо последнюю ошибку. + * + * Повторяем, потому что на этом шаге терять нечего: портал принимает доклад по заданию + * только пока оно в работе, повторный по уже закрытому он отвергает сам. + */ +async function reportWithRetries(send, config, attempts = 3) { + const pauseMs = Number(config?.reportRetryDelayMs ?? 3000); + let last = null; + + for (let i = 1; i <= attempts; i += 1) { + try { + await send(); + + return null; + } catch (e) { + last = e; + if (i < attempts && pauseMs > 0) { + await new Promise((resolve) => { setTimeout(resolve, pauseMs); }); + } + } + } + + return last; +} diff --git a/bots/yandex-creatives/src/session.js b/bots/yandex-creatives/src/session.js new file mode 100644 index 00000000..abcb61d2 --- /dev/null +++ b/bots/yandex-creatives/src/session.js @@ -0,0 +1,46 @@ +/** + * Живость входа в кабинет Директа. Разметка снята живьём — docs/cabinet-flow.md §1. + * + * Проверяем ДВА условия, потому что «страница отдалась» ещё не значит «мы внутри»: + * перебросило на паспорт — вход точно слетел; страница открылась, а кабинета на ней + * нет — вход тоже не рабочий. + */ +const OVERVIEW_PATH = '/wizard/overview/'; +const PASSPORT = 'https://passport.yandex.ru/'; + +// Устойчивый признак — блок баланса в боковом меню. Запасной — карточка пользователя: +// у кабинетов без права видеть счёт блока баланса может не быть. +const BALANCE = '[data-testid="Sidebar.BalanceExpanded"]'; +const USER_CARD = '[data-testid="SidebarUserControl"]'; + +export function overviewUrl(config) { + return `${config.cabinetUrl}${OVERVIEW_PATH}?ulogin=${encodeURIComponent(config.cabinetLogin)}`; +} + +export async function isLoggedIn(page, config, { timeoutMs = 15000 } = {}) { + try { + await page.goto(overviewUrl(config), { waitUntil: 'domcontentloaded' }); + } catch { + return false; + } + + if (String(page.url()).startsWith(PASSPORT)) { + return false; + } + + try { + await page.locator(BALANCE).waitFor({ timeout: timeoutMs }); + + return true; + } catch { + // Баланса нет — даём второй шанс по карточке пользователя, но уже без долгого ожидания: + // страница к этому моменту прогрузилась целиком. + try { + await page.locator(USER_CARD).waitFor({ timeout: 2000 }); + + return true; + } catch { + return false; + } + } +} diff --git a/bots/yandex-creatives/src/smtp.js b/bots/yandex-creatives/src/smtp.js new file mode 100644 index 00000000..82d45d81 --- /dev/null +++ b/bots/yandex-creatives/src/smtp.js @@ -0,0 +1,16 @@ +import nodemailer from 'nodemailer'; + +/** + * Настоящий почтовый транспорт. Вынесен из mailer.js отдельным файлом нарочно — по той же + * причине, что и human.js: `nodemailer` локально не установлен (зависимости ставятся только + * на боевом сервере), и пока он импортировался из mailer.js, тексты писем нельзя было + * проверить ни одним тестом. Теперь mailer.js чистый, а сюда никто, кроме bin/, не ходит. + */ +export function smtpTransport(smtp) { + return nodemailer.createTransport({ + host: smtp.host, + port: smtp.port, + secure: false, + auth: { user: smtp.user, pass: smtp.pass }, + }); +} diff --git a/bots/yandex-creatives/test/cabinet.test.js b/bots/yandex-creatives/test/cabinet.test.js new file mode 100644 index 00000000..ffe623b7 --- /dev/null +++ b/bots/yandex-creatives/test/cabinet.test.js @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { anteroomUrl, uploadCreatives } from '../src/cabinet.js'; + +const config = { + cabinetUrl: 'https://direct.yandex.ru', + cabinetLogin: 'nash-login', + anteroomCampaignId: '713051718', + anteroomGroupId: '5776749172', + humanDelayMs: 0, +}; + +const FILES = ['/tmp/300x250.jpg', '/tmp/728x90.jpg']; +const FAST = { acceptTimeoutMs: 60, modalCloseTimeoutMs: 60, pollMs: 5 }; + +/** + * Поддельная страница: записывает всё, что робот делал, и умеет притворяться, + * что кнопка «Создать» разблокировалась не сразу, а окно закрылось или нет. + */ +function fakePage({ + enabledAfter = 1, + // Умолчание — то, что происходит ЖИВЬЁМ: окно после «Создать» не закрывается, + // а переключается на вкладку «Мои креативы» (cabinet-flow.md §5.2). + modalCloses = false, + modalText = '', + leavingThrows = false, +} = {}) { + const actions = []; + let disabledAsked = 0; + + return { + actions, + clicked: () => actions.filter((a) => a.type === 'click').map((a) => a.selector), + asked: () => disabledAsked, + async goto(url) { + actions.push({ type: 'goto', url }); + // Уход со страницы после «Создать» — последний шаг; форму открывали раньше. + if (leavingThrows && !url.includes('banners-edit')) throw new Error('сеть икнула на уходе'); + }, + locator(selector) { + const self = { + // Настоящий Playwright в строгом режиме падает на нескольких совпадениях — + // поэтому боевой код везде зовёт .first(). Подделка обязана это уметь. + first: () => self, + async waitFor(options = {}) { + actions.push({ type: 'waitFor', selector, state: options.state }); + if (options.state === 'detached' && !modalCloses) throw new Error('окно не закрылось'); + }, + async click() { actions.push({ type: 'click', selector }); }, + async setInputFiles(files) { actions.push({ type: 'files', selector, files }); }, + async isDisabled() { disabledAsked += 1; return disabledAsked < enabledAfter; }, + async innerText() { return modalText; }, + }; + + return self; + }, + }; +} + +test('открывает форму нового объявления в «прихожей», а не чужое живое объявление', () => { + assert.equal( + anteroomUrl(config), + 'https://direct.yandex.ru/dna/banners-edit?ulogin=nash-login&campaigns-ids=713051718&groups-ids=5776749172&is-new=1', + ); +}); + +test('проходит путь до окна загрузки и кладёт файлы в поле кабинета', async () => { + const page = fakePage(); + + await uploadCreatives(page, config, FILES, FAST); + + assert.deepEqual(page.clicked(), [ + '[data-testid="ImageCreativeEditor.OpenMenu"]', + '[data-testid="ImageCreativeEditor.Menu.add-html5"]', + '[data-testid="CanvasFooter.SubmitButton"]', + // Живьём окно само не уходит — закрываем крестиком, см. ниже отдельный тест. + '[data-testid="CanvasModal.html5.CloseButton"]', + ]); + + const drop = page.actions.find((a) => a.type === 'files'); + assert.equal(drop.selector, '[data-testid="CanvasModal.html5"] input[type=file]'); + assert.deepEqual(drop.files, FILES); +}); + +/** + * 🔴 Поле формы `CreativeActionsMenu.FileInput` файлы ЗАБИРАЕТ, но Яндекс их не принимает: + * кнопка «Создать» остаётся серой, и робот честно падает «кабинет не принял файлы» — + * каждый раз, на безупречной работе. Разметку 27.07 сняли глазами, файлов не грузили, + * и поле выбрали по виду. Живая загрузка 28.07 показала: работает только поле ВНУТРИ + * окна загрузки (cabinet-flow.md §0 и §5.2). + */ +test('в поле формы, которое Яндекс молча глотает, файлы НЕ кладём', async () => { + const page = fakePage(); + + await uploadCreatives(page, config, FILES, FAST); + + assert.equal( + page.actions.some((a) => a.selector === '[data-testid="CreativeActionsMenu.FileInput"]'), + false, + ); +}); + +test('НИКОГДА не нажимает «Сохранить изменения» — единственную кнопку, меняющую кабинет', async () => { + const page = fakePage(); + + await uploadCreatives(page, config, FILES, FAST); + + assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); + assert.equal(page.actions.some((a) => a.selector === '[data-testid="SaveBannerButton"]'), false); +}); + +test('ждёт, пока кнопка «Создать» разблокируется — это признак «Яндекс принял файлы»', async () => { + const page = fakePage({ enabledAfter: 4 }); + + await uploadCreatives(page, config, FILES, FAST); + + // Кнопку спрашивали несколько раз подряд — значит робот действительно ждал, а не + // жал вслепую. Уберёшь ожидание — спросов станет меньше и тест покраснеет. + assert.ok(page.asked() >= 4, `кнопку спросили всего ${page.asked()} раз`); + + const order = page.actions.filter((a) => a.type === 'files' || (a.type === 'click' && a.selector.includes('SubmitButton'))); + assert.equal(order[0].type, 'files'); + assert.equal(order[1].type, 'click'); +}); + +test('бросает понятную ошибку, если кабинет так и не принял файлы', async () => { + const page = fakePage({ enabledAfter: Number.MAX_SAFE_INTEGER }); + + await assert.rejects(() => uploadCreatives(page, config, FILES, FAST), /не принял файлы/); +}); + +/** + * 🔑 Как оно на самом деле, снято живьём 28.07.2026: окно после «Создать» НЕ закрывается, + * оно переключается на вкладку «Мои креативы». Значит «не закрылось» — это НОРМА. + * Робот закрывает окно крестиком и уходит, ничего не объявляя провалом. + * + * 🔴 Признак «набор создан» с экрана мы намеренно НЕ читаем: подтверждённой метки у нас + * нет, а гадать по экрану — способ получить мёртвую программу при зелёных тестах. + * Правду об успехе знает портал по слепку creatives.get. + */ +test('окно после «Создать» не закрылось — это норма: закрываем крестиком и уходим', async () => { + const page = fakePage({ modalText: 'Мои креативы 300×250' }); + + const result = await uploadCreatives(page, config, FILES, FAST); + + assert.equal(result.modalClosed, false); + // Слова кабинета уносим наружу — по ним разбирают сбой, если он всё-таки был. + assert.equal(result.cabinetSaid, 'Мои креативы 300×250'); + // Крестик тут нажать НАДО: окно само не уйдёт, а робот обязан уйти со страницы чистым. + assert.ok(page.clicked().includes('[data-testid="CanvasModal.html5.CloseButton"]')); + assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); +}); + +test('окно закрылось само — тоже нормальный исход, крестик тогда не трогаем', async () => { + const page = fakePage({ modalCloses: true }); + + const result = await uploadCreatives(page, config, FILES, FAST); + + assert.equal(result.modalClosed, true); + assert.equal(result.cabinetSaid, ''); + assert.equal(page.clicked().includes('[data-testid="CanvasModal.html5.CloseButton"]'), false); +}); + +test('сетевая икота на уходе со страницы не считается провалом загрузки', async () => { + // Файлы уже приняты, «Создать» нажата — работа сделана. Уход со страницы это уборка + // за собой, и её осечка не должна выдаваться за «креативы не загрузились»: портал + // пометил бы задание сбойным, а креативы остались бы в кабинете. + const page = fakePage({ leavingThrows: true }); + + const result = await uploadCreatives(page, config, FILES, FAST); + + assert.equal(result.modalClosed, false); + assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); +}); + +test('пустой список файлов — сразу отказ, в кабинет не ходим', async () => { + const page = fakePage(); + + await assert.rejects(() => uploadCreatives(page, config, [], FAST), /пуст/); + assert.deepEqual(page.actions, []); +}); diff --git a/bots/yandex-creatives/test/config.test.js b/bots/yandex-creatives/test/config.test.js new file mode 100644 index 00000000..1e5ad89b --- /dev/null +++ b/bots/yandex-creatives/test/config.test.js @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; + +const full = { + YC_BROWSER_PROFILE_DIR: '/var/lib/liderra-robot/profile', + YC_CABINET_URL: 'https://direct.yandex.ru', + PORTAL_BASE_URL: 'https://lk.liderra.ru', + CREATIVE_ROBOT_TOKEN: 'secret', + SMTP_HOST: 'smtp.example', SMTP_PORT: '587', SMTP_USER: 'u', SMTP_PASS: 'p', + ALARM_FROM: 'bot@liderra.ru', ALARM_TO: 'ops@liderra.ru', + YC_CABINET_LOGIN: 'nash-login', + YC_ANTEROOM_CAMPAIGN_ID: '713051718', + YC_ANTEROOM_GROUP_ID: '5776749172', +}; + +test('загружает полный конфиг', () => { + const c = loadConfig(full); + assert.equal(c.portalBaseUrl, 'https://lk.liderra.ru'); + assert.equal(c.robotToken, 'secret'); + assert.equal(c.humanDelayMs, 800); +}); + +test('знает паузу между повторами доклада порталу', () => { + assert.equal(loadConfig(full).reportRetryDelayMs, 3000); + assert.equal(loadConfig({ ...full, REPORT_RETRY_DELAY_MS: '500' }).reportRetryDelayMs, 500); +}); + +// 🪤 `Number(env.X ?? '800')` ловит только undefined. Пустое значение в .env +// (`HUMAN_DELAY_MS=`) даёт пустую строку, а Number('') — это 0: робот начинал бы щёлкать +// по кабинету с машинной скоростью вместо человекоподобного темпа, и именно так выглядит +// поведение бота для антифрода Яндекса. +test('пустое значение паузы в окружении читается как значение по умолчанию, а не как ноль', () => { + assert.equal(loadConfig({ ...full, HUMAN_DELAY_MS: '' }).humanDelayMs, 800); + assert.equal(loadConfig({ ...full, REPORT_RETRY_DELAY_MS: '' }).reportRetryDelayMs, 3000); +}); + +// Мусор в значении молча превращался в NaN: пауза становилась «никакой», а порт почты — +// NaN, и письма переставали уходить без единого внятного слова в журнале. Пустое значение +// и опечатка — разные вещи: пустое значит «не задавал, возьми обычное», опечатка значит +// «человек хотел что-то задать и ошибся». Про второе надо сказать вслух, а не подставлять +// умолчание молча. +test('мусор в значении паузы падает с понятным сообщением', () => { + assert.throws(() => loadConfig({ ...full, HUMAN_DELAY_MS: 'быстро' }), /HUMAN_DELAY_MS/); +}); + +test('нечисловой порт почты падает с понятным сообщением, а не превращается в NaN', () => { + assert.throws(() => loadConfig({ ...full, SMTP_PORT: 'пятьсот' }), /SMTP_PORT/); +}); + +test('падает с понятным сообщением, если нет обязательной переменной', () => { + const { CREATIVE_ROBOT_TOKEN, ...without } = full; + assert.throws(() => loadConfig(without), /CREATIVE_ROBOT_TOKEN/); +}); + +test('срезает хвостовой слеш у адреса портала, чтобы не собирать двойные слеши', () => { + assert.equal(loadConfig({ ...full, PORTAL_BASE_URL: 'https://lk.liderra.ru/' }).portalBaseUrl, 'https://lk.liderra.ru'); +}); + +test('срезает хвостовой слеш и у адреса кабинета', () => { + assert.equal(loadConfig({ ...full, YC_CABINET_URL: 'https://direct.yandex.ru/' }).cabinetUrl, 'https://direct.yandex.ru'); +}); + +test('знает логин кабинета и «прихожую» — кампанию с группой, форму которой открывает робот', () => { + const c = loadConfig(full); + assert.equal(c.cabinetLogin, 'nash-login'); + assert.equal(c.anteroomCampaignId, '713051718'); + assert.equal(c.anteroomGroupId, '5776749172'); +}); + +test('требует «прихожую»: без неё роботу негде открыть окно загрузки', () => { + const { YC_ANTEROOM_GROUP_ID, ...without } = full; + assert.throws(() => loadConfig(without), /YC_ANTEROOM_GROUP_ID/); +}); diff --git a/bots/yandex-creatives/test/inspect.test.js b/bots/yandex-creatives/test/inspect.test.js new file mode 100644 index 00000000..5824e44f --- /dev/null +++ b/bots/yandex-creatives/test/inspect.test.js @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { rejectionUrl, readRejection, bezKnopokKabineta } from '../src/cabinet.js'; + +const config = { + cabinetUrl: 'https://direct.yandex.ru', + cabinetLogin: 'nash-login', + humanDelayMs: 0, +}; + +const JOB = { yandex_campaign_id: 713110757, yandex_ad_id: 17787055204 }; +const FAST = { popupTimeoutMs: 60, pollMs: 5 }; + +const CELL = '[data-testid="Grid.Cell-17787055204_aggregated-status-info"]'; +const POPUP = '[data-testid="BannerStatusCell.Popup"]'; + +/** + * Поддельная страница списка объявлений. Помнит всё, что робот делал, и умеет + * притворяться, что ячейки объявления нет или что окно причины не открылось. + */ +/** + * Поддельное окно повторяет главную повадку живого: текст в нём НАРАСТАЕТ. Пока строка + * причины не раскрыта, видно один заголовок; подробность появляется только после клика. + * Снято живьём 28.07.2026: 29 знаков до раскрытия против 785 после. + */ +function fakePage({ + cellExists = true, + cellLate = false, + popupOpens = true, + rowExists = true, + rowLate = false, + header = 'Модератор отклонил объявление', + text = '', +} = {}) { + const actions = []; + let раскрыто = false; + + return { + actions, + clicked: () => actions.filter((a) => a.type === 'click').map((a) => a.selector), + visited: () => actions.filter((a) => a.type === 'goto').map((a) => a.url), + locator(selector) { + const self = { + // 🪤 `count()` у Playwright НЕ ждёт — отвечает тем, что есть на экране прямо сейчас. + // Живой кабинет рисует список ~2 с, поэтому «поздняя» ячейка отвечает нулём. + async count() { + if (selector === CELL) return cellExists && !cellLate ? 1 : 0; + if (selector.includes('RejectRow')) return rowExists && !rowLate ? 1 : 0; + if (selector.includes('MiniGridRow')) return раскрыто ? 1 : 0; + return 1; + }, + first: () => self, + locator: (inner) => fakePageChild(inner), + async click() { + actions.push({ type: 'click', selector }); + if (selector.includes('RejectRow')) раскрыто = true; + }, + async waitFor() { + actions.push({ type: 'waitFor', selector }); + if (selector === CELL && !cellExists) throw new Error('ячейка так и не появилась'); + if (selector === POPUP && !popupOpens) throw new Error('окно не открылось'); + if (selector.includes('RejectRow') && !rowExists) throw new Error('строки причины нет'); + if (selector.includes('MiniGridRow') && !раскрыто) throw new Error('не раскрылось'); + }, + async innerText() { return раскрыто ? text : header; }, + async screenshot(opts) { actions.push({ type: 'shot', selector, path: opts?.path }); }, + }; + + function fakePageChild(inner) { + const child = { + first: () => child, + async click() { actions.push({ type: 'click', selector: inner }); }, + async count() { return 1; }, + }; + + return child; + } + + return self; + }, + async goto(url) { actions.push({ type: 'goto', url }); }, + }; +} + +test('идёт в СПИСОК объявлений, а не на форму — форма про модерацию не знает', () => { + assert.equal( + rejectionUrl(config, JOB), + 'https://direct.yandex.ru/dna/grid/banners?ulogin=nash-login&campaigns-ids=713110757&status-filter=ALL', + ); +}); + +test('приносит текст причины и снимок окна', async () => { + const page = fakePage({ text: 'Модератор отклонил объявление\nНет предупреждения: финансовые услуги' }); + + const said = await readRejection(page, config, JOB, '/tmp/shot.png', FAST); + + assert.match(said.report, /Нет предупреждения: финансовые услуги/); + assert.equal(said.screenshotPath, '/tmp/shot.png'); + assert.ok(page.actions.some((a) => a.type === 'shot')); +}); + +/** + * 🔴 Разведка НИЧЕГО не меняет в кабинете. Сторож «не нажимать Сохранить» распространяется + * и на неё; заодно робот не должен вообще заходить на форму объявления — там живут кнопки, + * которые кабинет меняют. + */ +test('НИЧЕГО не меняет: ни «Сохранить», ни захода на форму объявления', async () => { + const page = fakePage({ text: 'Нет предупреждения: финансовые услуги' }); + + await readRejection(page, config, JOB, '/tmp/shot.png', FAST); + + assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); + assert.equal(page.clicked().includes('[data-testid="StartCampaignButton"]'), false); + assert.equal(page.visited().some((u) => u.includes('banners-edit')), false); +}); + +/** + * 🪤 Список объявлений врёт в обе стороны: во время модерации отдавал «Нет объявлений», + * после отказа то же объявление показал. Поэтому робот ищет СВОЙ номер, а не «первую + * попавшуюся строку с отказом» — иначе принесёт клиенту причину чужого объявления. + */ +test('своего объявления в списке нет — честно сдаётся, чужую строку не берёт', async () => { + const page = fakePage({ cellExists: false }); + + await assert.rejects( + () => readRejection(page, config, JOB, '/tmp/shot.png', FAST), + /17787055204/, + ); +}); + +/** + * 🔴 Найдено ЖИВЫМ прогоном 28.07.2026, тесты этого не ловили. + * + * Кабинет рисует список объявлений примерно за 2 секунды, а робот считал ячейки сразу + * после человекоподобной паузы (по умолчанию 800 мс). `count()` не ждёт — он отвечает + * «ноль» и робот докладывал «объявления в кабинете нет». На бою это значило бы: КАЖДЫЙ + * отказ уезжает в «ждёт разбора», а клиент причину не узнаёт никогда. + * + * Отличать «ещё не отрисовалось» от «объявления правда нет» умеет только ожидание. + */ +test('ячейка появляется не сразу — робот ЖДЁТ её, а не сдаётся мгновенно', async () => { + const page = fakePage({ cellLate: true, text: 'Нет предупреждений: медицина' }); + + const said = await readRejection(page, config, JOB, '/tmp/shot.png', FAST); + + assert.match(said.report, /Нет предупреждений: медицина/); +}); + +/** + * 🔴 Вторая поломка того же живого прогона 28.07.2026 — и ровно та же ошибка, что с ячейкой. + * + * Окно открывается раньше, чем в нём появляется строка причины. Робот считал строки + * через `count()` сразу после паузы, получал ноль, раскрывать было нечего — и уносил + * клиенту ОДИН ЗАГОЛОВОК «Модератор отклонил объявление». Живьём это 29 знаков вместо 785. + * Клиент получил бы сообщение от Яндекса, в котором нет ни слова о том, что чинить. + */ +test('строка причины появляется не сразу — робот ЖДЁТ её и раскрывает', async () => { + const ПРИЧИНА = 'Модератор отклонил объявление Нет предупреждения: финансовые услуги ' + + 'Объявление о финансовых услугах должно содержать наименование юридического лица'; + const page = fakePage({ rowLate: true, text: ПРИЧИНА }); + + const said = await readRejection(page, config, JOB, '/tmp/shot.png', FAST); + + assert.match(said.report, /должно содержать наименование юридического лица/); + assert.ok(page.clicked().some((s) => s.includes('RejectRow')), 'строку причины не раскрыли'); +}); + +/** + * Заголовок без причины — это не доклад, а пустота: клиент и так знает, что его отклонили. + * Лучше честно сдаться и позвать человека через «ждёт разбора», чем прислать бесполезное. + */ +test('строки причины в окне нет вовсе — сдаётся, один заголовок клиенту не шлёт', async () => { + const page = fakePage({ rowExists: false, text: 'сюда робот дойти не должен' }); + + await assert.rejects( + () => readRejection(page, config, JOB, '/tmp/shot.png', FAST), + /причин/i, + ); +}); + +/** + * Хвост доклада. В текст окна входят подписи кнопок «Написать в чат» и «Написать письмо» — + * это кнопки НАШЕГО кабинета поставщика. У клиента их нет и быть не может, а выглядят они + * как приглашение написать Яндексу. Решение владельца 28.07.2026: две строки срезать, + * слова Яндекса про причину не трогать. + */ +test('срезает подписи кнопок кабинета, слова Яндекса не трогает', () => { + const доклад = [ + 'Модератор отклонил объявление', + 'Нет предупреждений: медицина', + 'Площадь предупреждения — не менее 5% площади объявления.', + '', + 'Написать в чат', + 'Написать письмо', + ].join('\n'); + + const итог = bezKnopokKabineta(доклад); + + assert.match(итог, /Нет предупреждений: медицина/); + assert.match(итог, /не менее 5%/); + assert.doesNotMatch(итог, /Написать в чат/); + assert.doesNotMatch(итог, /Написать письмо/); +}); + +/** + * 🪤 Живой прогон 28.07.2026: «Написать письмо» срезалось, а «Написать в чат» оставалось. + * Яндекс ставит между короткими словами НЕРАЗРЫВНЫЙ пробел — это другой символ, код 160, + * и точное сравнение его не узнаёт. Видно по соседней строке: «не·идут» тоже через него. + */ +test('узнаёт кнопку, написанную через неразрывный пробел', () => { + const доклад = 'Нет предупреждений: медицина\nНаписать в чат\nНаписать письмо'; + + assert.equal(bezKnopokKabineta(доклад), 'Нет предупреждений: медицина'); +}); + +/** Режем только ХВОСТ: те же слова в середине пояснения — это слова Яндекса, они остаются. */ +test('те же слова в середине пояснения не трогает', () => { + const доклад = 'Написать письмо в поддержку можно из кабинета.\nНет предупреждений: медицина'; + + assert.equal(bezKnopokKabineta(доклад), доклад); +}); + +test('окно причины не открылось — сдаётся, ничего не выдумывает', async () => { + const page = fakePage({ popupOpens: false }); + + await assert.rejects(() => readRejection(page, config, JOB, '/tmp/shot.png', FAST), /окн/i); +}); + +/** Окно открылось, а текста в нём нет — это не причина, а пустота. Врать клиенту нельзя. */ +test('окно пустое — сдаётся, пустой доклад не отдаёт', async () => { + const page = fakePage({ text: ' ' }); + + await assert.rejects(() => readRejection(page, config, JOB, '/tmp/shot.png', FAST), /пуст/i); +}); diff --git a/bots/yandex-creatives/test/lock.test.js b/bots/yandex-creatives/test/lock.test.js new file mode 100644 index 00000000..4e294f79 --- /dev/null +++ b/bots/yandex-creatives/test/lock.test.js @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { acquireLock } from '../src/lock.js'; + +async function withDir(fn) { + const dir = await mkdtemp(join(tmpdir(), 'yc-lock-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('свободный замок берётся, файл замка появляется на диске', async () => { + await withDir(async (dir) => { + const path = join(dir, 'robot.lock'); + + const lock = acquireLock(path); + + assert.equal(lock.ok, true); + assert.equal(existsSync(path), true); + + lock.release(); + assert.equal(existsSync(path), false); + }); +}); + +/** + * Ради чего всё: Chromium держит папку профиля под замком, и второй процесс просто + * не стартует. Раньше это падало ВНУТРИ рабочего блока — и задание помечалось сбойным, + * хотя ничего не сломано: просто расписание запустило проход поверх ещё не закончившегося + * (README предписывает гонять проход часто, а поддержание входа — раз в ~15 минут). + */ +test('занятый замок вторым процессом не берётся', async () => { + await withDir(async (dir) => { + const path = join(dir, 'robot.lock'); + const first = acquireLock(path); + + const second = acquireLock(path); + + assert.equal(first.ok, true); + assert.equal(second.ok, false); + + first.release(); + assert.equal(acquireLock(path).ok, true, 'после освобождения замок снова доступен'); + }); +}); + +/** + * Обратная сторона: процесс убили насмерть (перезагрузка сервера) — снять замок некому, + * и робот не работал бы уже никогда. Поэтому просроченный замок перехватывается. + */ +test('просроченный замок перехватывается', async () => { + await withDir(async (dir) => { + const path = join(dir, 'robot.lock'); + await writeFile(path, JSON.stringify({ pid: 999999, at: Date.now() - 60 * 60 * 1000 })); + + const lock = acquireLock(path, { staleMs: 30 * 60 * 1000 }); + + assert.equal(lock.ok, true); + }); +}); + +test('свежий замок не перехватывается по сроку', async () => { + await withDir(async (dir) => { + const path = join(dir, 'robot.lock'); + await writeFile(path, JSON.stringify({ pid: 999999, at: Date.now() })); + + assert.equal(acquireLock(path, { staleMs: 30 * 60 * 1000 }).ok, false); + }); +}); + +// Замок, испорченный до нечитаемого (обрыв записи, кто-то потрогал руками), не должен +// запирать робота навсегда: непонятное содержимое считаем просроченным. +test('нечитаемый замок не запирает робота навсегда', async () => { + await withDir(async (dir) => { + const path = join(dir, 'robot.lock'); + await writeFile(path, 'мусор, не json'); + + assert.equal(acquireLock(path).ok, true); + }); +}); + +test('повторное освобождение не падает', async () => { + await withDir(async (dir) => { + const lock = acquireLock(join(dir, 'robot.lock')); + + lock.release(); + lock.release(); + }); +}); diff --git a/bots/yandex-creatives/test/mailer.test.js b/bots/yandex-creatives/test/mailer.test.js new file mode 100644 index 00000000..4db529ce --- /dev/null +++ b/bots/yandex-creatives/test/mailer.test.js @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMailer } from '../src/mailer.js'; + +function stubTransport() { + const sent = []; + + return { sent, sendMail: async (m) => { sent.push(m); } }; +} + +test('алярм до загрузки честно говорит, что в кабинете ничего не изменилось', async () => { + const t = stubTransport(); + + await createMailer(t, { from: 'bot@liderra.ru', to: 'ops@liderra.ru' }) + .alarm({ step: 'проверка входа', reason: 'вход слетел', campaignId: 42, screenshotPath: null }); + + assert.match(t.sent[0].text, /ничего в кабинете не менял/); + assert.match(t.sent[0].text, /деньги не потрачены/); +}); + +/** + * Письмо было одно на все случаи и всегда утверждало «робот остановился и ничего + * в кабинете не менял». А тем же письмом сообщали, например, «окно загрузки не закрылось» — + * там файлы УЖЕ залиты и робот уже отчитался «готово». Человек читал, что ничего + * не произошло, и в кабинет не шёл. А идти надо: там лежат креативы, и повторная заливка + * оставит дубли, которые вычищаются только руками. + */ +test('алярм после загрузки не врёт, что ничего не менялось, и зовёт человека в кабинет', async () => { + const t = stubTransport(); + + await createMailer(t, { from: 'bot@liderra.ru', to: 'ops@liderra.ru' }) + .alarm({ step: 'доклад порталу', reason: 'портал не ответил', campaignId: 42, screenshotPath: null, uploaded: true }); + + const text = t.sent[0].text; + assert.doesNotMatch(text, /ничего в кабинете не менял/); + assert.doesNotMatch(text, /деньги не потрачены/); + assert.match(text, /кабинет/i); + assert.match(text, /дубли/); +}); diff --git a/bots/yandex-creatives/test/portal.test.js b/bots/yandex-creatives/test/portal.test.js new file mode 100644 index 00000000..2795256f --- /dev/null +++ b/bots/yandex-creatives/test/portal.test.js @@ -0,0 +1,256 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createPortal } from '../src/portal.js'; + +const config = { portalBaseUrl: 'https://lk.liderra.ru', robotToken: 'secret' }; + +test('спрашивает работу и возвращает задание', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ job: { id: 7, campaign_id: 42, banners: [] } }) }; + }; + + const portal = createPortal(config, fetchStub); + const job = await portal.takeJob(); + + assert.equal(job.id, 7); + assert.equal(calls[0].url, 'https://lk.liderra.ru/api/creative-robot/next'); + assert.equal(calls[0].opts.headers['X-Creative-Robot-Token'], 'secret'); +}); + +test('возвращает null, когда работы нет', async () => { + const fetchStub = async () => ({ ok: true, status: 200, json: async () => ({ job: null }) }); + assert.equal(await createPortal(config, fetchStub).takeJob(), null); +}); + +test('бросает понятную ошибку при отказе портала', async () => { + const fetchStub = async () => ({ ok: false, status: 401, json: async () => ({}) }); + await assert.rejects(() => createPortal(config, fetchStub).takeJob(), /401/); +}); + +test('скачивает файл баннера на диск, создавая недостающую папку', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, body: new Blob(['картинка']).stream() }; + }; + + const dir = await mkdtemp(join(tmpdir(), 'yc-robot-')); + // Путь передаём БЕЗ расширения — его дописывает сам портал по типу содержимого. + const target = join(dir, 'нет-такой-папки', '1-300x250'); + + try { + const written = await createPortal(config, fetchStub).downloadBanner( + { file_url: 'https://lk.liderra.ru/api/creative-robot/jobs/7/banners/1/file', banner_id: 1, width: 300, height: 250 }, + target, + ); + + assert.equal(written, `${target}.jpg`); + assert.equal(await readFile(written, 'utf8'), 'картинка'); + assert.equal(calls[0].opts.headers['X-Creative-Robot-Token'], 'secret'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Клиенту разрешены jpg, png и gif. Раньше робот сохранял любой файл под именем «.jpg» +// и таким же скармливал его кабинету Яндекса: PNG приезжал туда как «картинка.jpg». +// Расширение берём из типа содержимого, который назвал портал, а не выдумываем. +test('сохраняет файл под настоящим расширением из типа содержимого', async () => { + const fetchStub = async () => ({ + ok: true, + status: 200, + headers: { get: (name) => (name.toLowerCase() === 'content-type' ? 'image/png' : null) }, + body: new Blob(['картинка']).stream(), + }); + + const dir = await mkdtemp(join(tmpdir(), 'yc-robot-')); + + try { + const written = await createPortal(config, fetchStub).downloadBanner( + { file_url: 'https://lk.liderra.ru/api/creative-robot/jobs/7/banners/1/file', banner_id: 1, width: 300, height: 250 }, + join(dir, '1-300x250'), + ); + + assert.equal(written, join(dir, '1-300x250.png')); + assert.equal(await readFile(written, 'utf8'), 'картинка'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('без внятного типа содержимого сохраняет файл как jpg', async () => { + const fetchStub = async () => ({ + ok: true, + status: 200, + headers: { get: () => null }, + body: new Blob(['картинка']).stream(), + }); + + const dir = await mkdtemp(join(tmpdir(), 'yc-robot-')); + + try { + const written = await createPortal(config, fetchStub).downloadBanner( + { file_url: 'https://lk.liderra.ru/api/creative-robot/jobs/7/banners/2/file', banner_id: 2, width: 728, height: 90 }, + join(dir, '2-728x90'), + ); + + assert.equal(written, join(dir, '2-728x90.jpg')); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('бросает понятную ошибку с размером баннера, если файл не отдался', async () => { + const fetchStub = async () => ({ ok: false, status: 404, body: null }); + + await assert.rejects( + () => createPortal(config, fetchStub).downloadBanner( + { file_url: 'https://lk.liderra.ru/api/creative-robot/jobs/7/banners/1/file', width: 300, height: 250 }, + join(tmpdir(), 'не-должен-появиться.jpg'), + ), + /300x250.*404/s, + ); +}); + +// Адрес файла робот берёт из ответа сервера и идёт по нему СО СВОИМ ТОКЕНОМ. Подменённый +// или просто перепутанный адрес увёл бы ключ от служебного канала на чужой сервер, а робот +// живёт на боевой машине и ходит по внутренней сети. Токен уходит только на портал. +test('не несёт токен на чужой адрес', async () => { + let called = false; + const fetchStub = async () => { called = true; return { ok: true, status: 200, body: null }; }; + + await assert.rejects( + () => createPortal(config, fetchStub).downloadBanner( + { file_url: 'https://evil.example/забрать', banner_id: 1, width: 300, height: 250 }, + join(tmpdir(), 'не-должен-появиться'), + ), + /evil\.example/, + ); + + assert.equal(called, false, 'до запроса дойти не должно — токен не уходит'); +}); + +test('мусор вместо адреса файла тоже отвергается', async () => { + let called = false; + const fetchStub = async () => { called = true; return { ok: true, status: 200, body: null }; }; + + await assert.rejects( + () => createPortal(config, fetchStub).downloadBanner( + { file_url: '/api/creative-robot/jobs/7/banners/1/file', banner_id: 1, width: 300, height: 250 }, + join(tmpdir(), 'не-должен-появиться'), + ), + ); + + assert.equal(called, false); +}); + +test('отчитывается об успехе', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'done' }) }; + }; + + await createPortal(config, fetchStub).reportDone(7); + + assert.equal(calls[0].url, 'https://lk.liderra.ru/api/creative-robot/jobs/7/done'); + assert.equal(JSON.parse(calls[0].opts.body).ok, true); +}); + +test('отчитывается о сбое с причиной', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'failed' }) }; + }; + + await createPortal(config, fetchStub).reportFailure(7, 'вход слетел'); + + const body = JSON.parse(calls[0].opts.body); + assert.equal(body.ok, false); + assert.equal(body.reason, 'вход слетел'); +}); + +test('слишком длинную причину обрезает — иначе портал отвергнет отчёт и очередь встанет колом', async () => { + // Портал принимает причину не длиннее 1024 знаков. Playwright при таймауте штатно + // вываливает «Call log:» на десятки строк — то есть САМЫЙ частый вид сбоя и есть тот, + // чей отчёт портал отверг бы. Отчёт не принят → задание навсегда «в работе» → выдача + // заданий возвращает «работы нет» ВСЕМ, ни одна кампания больше не стартует. + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + + return { ok: true, status: 200, json: async () => ({ status: 'failed' }) }; + }; + + await createPortal(config, fetchStub).reportFailure(7, `загрузка креативов: ${'ц'.repeat(5000)}`); + + const body = JSON.parse(calls[0].opts.body); + assert.ok(body.reason.length <= 900, `причина ушла длиной ${body.reason.length}`); + assert.match(body.reason, /^загрузка креативов: цц/, 'начало причины должно уцелеть'); + assert.ok(body.reason.endsWith('…'), 'обрезку надо показать человеку'); +}); + +test('везёт порталу доклад разведки вместе со снимком экрана', async () => { + const dir = await mkdtemp(join(tmpdir(), 'yc-portal-')); + const shot = join(dir, 'okno.png'); + await (await import('node:fs/promises')).writeFile(shot, 'снимок'); + + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'done' }) }; + }; + + try { + await createPortal(config, fetchStub) + .reportInspection(9, 'Нет предупреждения: финансовые услуги', shot); + + assert.equal(calls[0].url, 'https://lk.liderra.ru/api/creative-robot/jobs/9/inspection'); + assert.equal(calls[0].opts.method, 'POST'); + + const form = calls[0].opts.body; + assert.ok(form instanceof FormData, 'снимок можно отправить только формой'); + assert.match(form.get('report'), /финансовые услуги/); + assert.ok(form.get('screenshot'), 'снимок должен уехать вместе с докладом'); + + // 🔴 Content-Type руками не ставим: границу частей формы дописывает сам fetch, + // а подменённый заголовок портал разберёт как пустую форму. + assert.equal(calls[0].opts.headers?.['Content-Type'], undefined); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +/** Снимок не сделался или потерялся — причина всё равно должна доехать до клиента. */ +test('доклад разведки уезжает и без снимка', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'done' }) }; + }; + + await createPortal(config, fetchStub).reportInspection(9, 'Нет предупреждений: медицина', null); + + const form = calls[0].opts.body; + assert.match(form.get('report'), /медицина/); + assert.equal(form.get('screenshot'), null); +}); + +test('пропавший на диске снимок не срывает доклад', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'done' }) }; + }; + + await createPortal(config, fetchStub).reportInspection(9, 'причина', '/нет/такого/файла.png'); + + assert.equal(calls.length, 1, 'доклад обязан уехать даже без снимка'); + assert.equal(calls[0].opts.body.get('screenshot'), null); +}); diff --git a/bots/yandex-creatives/test/runner.test.js b/bots/yandex-creatives/test/runner.test.js new file mode 100644 index 00000000..6ae0a936 --- /dev/null +++ b/bots/yandex-creatives/test/runner.test.js @@ -0,0 +1,457 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runOnce } from '../src/runner.js'; + +const JOB = { + id: 7, + campaign_id: 42, + banners: [ + { banner_id: 1, width: 300, height: 250, file_url: 'https://lk.liderra.ru/f/1' }, + { banner_id: 2, width: 728, height: 90, file_url: 'https://lk.liderra.ru/f/2' }, + ], +}; + +async function withWorkDir(fn) { + const dir = await mkdtemp(join(tmpdir(), 'yc-runner-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function stubs({ + job = null, + uploadThrows = null, + uploadResult = { modalClosed: false, cabinetSaid: '' }, + loggedIn = true, + inspectThrows = null, + inspectResult = { report: 'Нет предупреждения: финансовые услуги', screenshotPath: '/tmp/shot.png' }, +} = {}) { + const sent = []; + const reports = []; + const downloaded = []; + const uploads = []; + const inspected = []; + let opened = 0; + let closed = 0; + + return { + sent, + reports, + downloaded, + uploads, + inspected, + opened: () => opened, + closed: () => closed, + portal: { + takeJob: async () => job, + downloadBanner: async (banner, path) => { downloaded.push(path); return path; }, + reportDone: async (id) => reports.push({ ok: true, id }), + reportFailure: async (id, reason) => reports.push({ ok: false, id, reason }), + reportInspection: async (id, report, shot) => reports.push({ inspection: true, id, report, shot }), + }, + browser: { + open: async () => { + opened += 1; + + return { context: { close: async () => { closed += 1; } }, page: { screenshot: async () => {} } }; + }, + isLoggedIn: async () => loggedIn, + uploadCreatives: async (page, config, files) => { + uploads.push(files); + if (uploadThrows) throw new Error(uploadThrows); + + return uploadResult; + }, + readRejection: async (page, config, j, shotPath) => { + inspected.push({ adId: j.yandex_ad_id, shotPath }); + if (inspectThrows) throw new Error(inspectThrows); + + return inspectResult; + }, + }, + mailer: { + alarm: async (m) => sent.push({ kind: 'alarm', ...m }), + report: async (m) => sent.push({ kind: 'report', ...m }), + }, + }; +} + +const opts = (workDir) => ({ timestamp: 't', workDir }); + +// Без пауз: в проверках ждать нечего, а повтор доклада порталу иначе тянул бы секунды. +const FAST_CONFIG = { humanDelayMs: 0, reportRetryDelayMs: 0 }; + +test('без работы ничего не делает и браузер не открывает', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: null }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.idle, true); + assert.equal(s.opened(), 0); + assert.equal(s.reports.length, 0); + }); +}); + +test('успешная загрузка отчитывается «готово» и шлёт письмо', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, true); + assert.deepEqual(s.reports, [{ ok: true, id: 7 }]); + assert.equal(s.sent[0].kind, 'report'); + assert.equal(s.sent[0].count, 2); + }); +}); + +test('в кабинет уходят ПУТИ к файлам, а не описания баннеров', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB }); + + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + const given = s.uploads[0]; + assert.equal(given.length, 2); + assert.ok(given.every((f) => typeof f === 'string'), `в кабинет ушло не то: ${JSON.stringify(given)}`); + assert.deepEqual(given, s.downloaded); + assert.ok(given[0].includes('300x250')); + }); +}); + +test('файлы клиента живут в переданной рабочей папке', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB }); + + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.ok(s.downloaded.every((p) => p.startsWith(workDir)), `файлы легли мимо: ${s.downloaded[0]}`); + }); +}); + +/** + * 🪤 Прежний тест на это был ПУСТЫШКОЙ: он всегда передавал рабочую папку явно, поэтому + * значение по умолчанию не проверялось вообще. Проверено вырезанием: подмена умолчания + * на `process.cwd()` оставляла все тесты зелёными. + * + * А умолчание тут и есть вся суть: робота запускает расписание, каталог запуска у него + * может быть любым. Считай мы папки от него — картинки клиента и снимки экрана + * с логином и остатком счёта раскидывало бы по случайным местам сервера. + * + * Поэтому запускаем ИЗ ЧУЖОГО каталога и без явной рабочей папки. + */ +test('без явной рабочей папки файлы всё равно ложатся к роботу, а не в каталог запуска', async () => { + const alien = await mkdtemp(join(tmpdir(), 'yc-alien-')); + const wasCwd = process.cwd(); + const robotRoot = fileURLToPath(new URL('..', import.meta.url)); + const s = stubs({ job: JOB }); + + try { + process.chdir(alien); + + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, { timestamp: 't' }); + + assert.ok(s.downloaded.length > 0, 'файлы должны были скачаться'); + assert.ok( + s.downloaded.every((p) => p.startsWith(robotRoot)), + `файлы легли мимо папки робота: ${s.downloaded[0]}`, + ); + assert.ok( + s.downloaded.every((p) => !p.startsWith(alien)), + `файлы легли в каталог запуска: ${s.downloaded[0]}`, + ); + } finally { + process.chdir(wasCwd); + // Робот чистит downloads сам, а вот папку снимков экрана он создаёт и оставляет. + await rm(join(robotRoot, 'downloads'), { recursive: true, force: true }); + await rm(join(robotRoot, 'screenshots'), { recursive: true, force: true }); + await rm(alien, { recursive: true, force: true }); + } +}); + +/** + * Снимки экрана копились навсегда. Это не просто мусор на диске: на снимке видна боковая + * панель кабинета — логин и остаток счёта. Чем дольше они лежат, тем больше на сервере + * копится того, что там лежать не должно. Свежие нужны — по ним разбирают вчерашний сбой, + * старые не нужны никому. + */ +test('старые снимки экрана убираются, свежие остаются', async () => { + await withWorkDir(async (workDir) => { + const shots = join(workDir, 'screenshots'); + mkdirSync(shots, { recursive: true }); + + const old = join(shots, 'job-1-старый.png'); + const fresh = join(shots, 'job-2-свежий.png'); + writeFileSync(old, 'снимок'); + writeFileSync(fresh, 'снимок'); + + const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + utimesSync(old, monthAgo, monthAgo); + + const s = stubs({ job: JOB }); + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(existsSync(old), false, 'месячной давности снимок должен был уйти'); + assert.equal(existsSync(fresh), true, 'свежий снимок нужен для разбора сбоя'); + }); +}); + +test('файлы клиента удаляются при любом исходе — это чужие картинки, копиться им нельзя', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' }); + + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(existsSync(join(workDir, 'downloads', 'job-7')), false); + assert.equal(s.closed(), 1, 'браузер должен закрыться даже при сбое'); + }); +}); + +test('сбой загрузки отчитывается сбоем и шлёт алярм с причиной', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB, uploadThrows: 'кнопка не найдена' }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, false); + assert.equal(s.reports[0].ok, false); + assert.match(s.reports[0].reason, /кнопка не найдена/); + assert.match(s.sent[0].reason, /кнопка не найдена/); + }); +}); + +test('слетевший вход не пытается грузить и отчитывается сбоем', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB, loggedIn: false }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, false); + assert.equal(s.uploads.length, 0); + assert.match(s.reports[0].reason, /вход/i); + }); +}); + +/** + * 🔴 Живьём окно загрузки НЕ закрывается: после «Создать» оно переключается на вкладку + * «Мои креативы» (cabinet-flow.md §5.2). Пока роботу это казалось бедой, он слал + * письмо-алярм на КАЖДОЙ удачной загрузке — а письмо, которое приходит всегда, + * перестают читать вовсе. + * + * 🔴 Настоящий сбой это не прячет: если креатив не появился, портал ловит это слепком + * creatives.get и закрывает задание сбоем — оно видно в админке «ждёт разбора». + */ +test('обычная удачная загрузка человека письмом НЕ дёргает', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ + job: JOB, + uploadResult: { modalClosed: false, cabinetSaid: 'Мои креативы 300×250' }, + }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, true); + assert.deepEqual(s.reports, [{ ok: true, id: 7 }]); + assert.equal( + s.sent.some((m) => m.kind === 'alarm'), + false, + `на удачной загрузке ушло письмо-алярм: ${JSON.stringify(s.sent)}`, + ); + }); +}); + +/** + * Слова кабинета не теряем: они уезжают в результат прохода и попадают в журнал робота. + * Но письмом по ним человека НЕ зовём — приговор об успехе выносит портал по слепку + * креативов, а не робот по экрану. + */ +test('слова кабинета уносим в результат прохода, но письмом по ним не зовём', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ + job: JOB, + uploadResult: { modalClosed: false, cabinetSaid: 'Файл слишком большой' }, + }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, true); + assert.equal(res.cabinetSaid, 'Файл слишком большой'); + assert.deepEqual(s.reports, [{ ok: true, id: 7 }]); + assert.equal(s.sent.some((m) => m.kind === 'alarm'), false); + }); +}); + +test('обрыв на докладе «готово» НЕ превращается в отчёт о сбое — креативы уже в кабинете', async () => { + await withWorkDir(async (workDir) => { + // Приговор об успехе выносит портал по слепку креативов. Если доклад «готово» не дошёл + // (502 от nginx, обрыв, таймаут), робот раньше падал в общий обработчик сбоя и слал + // «сбой» — на СВОЮ УДАЧНУЮ работу. Портал помечал задание сбойным, а креативы уже лежали + // в кабинете. Хуже того: если портал успел принять «готово», а ответ потерялся, «сбой» + // затирал правильный результат с уже проставленными номерами. + const s = stubs({ job: JOB }); + s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); }; + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal( + s.reports.filter((r) => r.ok === false).length, + 0, + `доложен сбой об удачной работе: ${JSON.stringify(s.reports)}`, + ); + const alarm = s.sent.find((m) => m.kind === 'alarm'); + assert.ok(alarm, 'человека надо позвать письмом'); + assert.match(alarm.reason, /502/); + assert.equal(res.ok, true, 'работа сделана: креативы в кабинете'); + }); +}); + +test('доклад «готово» повторяется, если с первого раза не прошёл', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB }); + let tries = 0; + s.portal.reportDone = async (id) => { + tries += 1; + if (tries < 3) throw new Error('обрыв связи'); + s.reports.push({ ok: true, id }); + }; + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(tries, 3, `доклад пробовали ${tries} раз(а)`); + assert.deepEqual(s.reports, [{ ok: true, id: 7 }]); + assert.equal(res.ok, true); + }); +}); + +// Письмо обязано честно сказать, что в кабинете уже лежат креативы. Пока оно было одно +// на все случаи и всегда утверждало «робот ничего не менял», человек читал письмо +// «окно загрузки не закрылось» и в кабинет не шёл — а идти надо: повторная заливка +// оставит дубли, вычистить которые можно только руками. +test('письма после заливки помечены «креативы уже в кабинете»', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB, uploadResult: { modalClosed: false, cabinetSaid: 'что-то не так' } }); + s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); }; + + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + const alarms = s.sent.filter((m) => m.kind === 'alarm'); + assert.ok(alarms.length > 0, 'человека надо позвать письмом'); + assert.ok(alarms.every((m) => m.uploaded === true), 'письма после заливки обязаны быть помечены'); + }); +}); + +test('письмо о сбое ДО заливки не помечено «креативы уже в кабинете»', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' }); + + await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + const alarm = s.sent.find((m) => m.kind === 'alarm'); + assert.ok(alarm); + assert.notEqual(alarm.uploaded, true, 'работа не сделана — врать в обратную сторону тоже нельзя'); + }); +}); + +test('портал не принял отчёт о сбое — человек узнаёт об этом из письма', async () => { + await withWorkDir(async (workDir) => { + // Раньше провал отчёта глотался молча. А это самый опасный исход: задание остаётся + // «в работе» навсегда, очередь встаёт колом для ВСЕХ клиентов, и никто об этом + // не знает — в письме написана только исходная причина сбоя. + const s = stubs({ job: JOB, uploadThrows: 'кнопка не найдена' }); + s.portal.reportFailure = async () => { throw new Error('Портал ответил 422 на /done'); }; + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, false); + const alarm = s.sent.find((m) => m.kind === 'alarm'); + assert.ok(alarm, 'письмо-алярм должно уйти'); + assert.match(alarm.reason, /кнопка не найдена/, 'исходная причина должна остаться'); + assert.match(alarm.reason, /422/, 'провал отчёта порталу должен быть виден человеку'); + }); +}); + +test('письмо не ушло — задание всё равно закрыто, робот не зависает', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: JOB, uploadThrows: 'кабинет упал' }); + s.mailer.alarm = async () => { throw new Error('почта недоступна'); }; + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, false); + assert.equal(s.reports[0].ok, false); + }); +}); + +// ── Разведка ────────────────────────────────────────────────────────────────────────── + +const INSPECT_JOB = { + id: 9, + campaign_id: 42, + kind: 'inspect', + yandex_campaign_id: 713110757, + yandex_ad_id: 17787055204, + banners: [], +}; + +/** + * 🔑 Разведка — единственный способ узнать, за что Яндекс отклонил рекламу: машине он этого + * не говорит. Робот читает экран кабинета и приносит текст порталу, а тот кладёт его + * клиенту в ленту. + */ +test('разведка приносит порталу причину и снимок, а картинки не возит', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: INSPECT_JOB }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, true); + assert.equal(s.inspected.length, 1); + assert.equal(s.inspected[0].adId, 17787055204); + assert.equal(s.uploads.length, 0, 'разведка ничего не грузит'); + assert.equal(s.downloaded.length, 0, 'разведке картинки клиента не нужны'); + assert.equal(s.reports.length, 1); + assert.equal(s.reports[0].inspection, true); + assert.match(s.reports[0].report, /Нет предупреждения/); + }); +}); + +/** + * 🔴 Робот не понял, что видит — задание сбойное, человека зовём письмом, а клиенту + * В ЛЕНТУ НИЧЕГО НЕ СОЧИНЯЕМ. Придуманная причина хуже молчания: по ней клиент пойдёт + * переделывать не то. + */ +test('разведка растерялась — сбой порталу и письмо, доклада в ленту нет', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: INSPECT_JOB, inspectThrows: 'окна причины нет' }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, false); + assert.equal(s.reports.length, 1); + assert.equal(s.reports[0].ok, false); + assert.match(s.reports[0].reason, /окна причины нет/); + assert.equal(s.reports.some((r) => r.inspection), false, 'доклад в ленту уходить не должен'); + assert.ok(s.sent.some((m) => m.kind === 'alarm'), 'владельца надо позвать письмом'); + }); +}); + +/** Вход в кабинет слетел — в кабинет не лезем и ничего не докладываем как причину. */ +test('разведка при слетевшем входе не читает экран', async () => { + await withWorkDir(async (workDir) => { + const s = stubs({ job: INSPECT_JOB, loggedIn: false }); + + const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); + + assert.equal(res.ok, false); + assert.equal(s.inspected.length, 0); + assert.equal(s.reports[0].ok, false); + }); +}); diff --git a/bots/yandex-creatives/test/session.test.js b/bots/yandex-creatives/test/session.test.js new file mode 100644 index 00000000..07caaaa5 --- /dev/null +++ b/bots/yandex-creatives/test/session.test.js @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isLoggedIn } from '../src/session.js'; + +const config = { cabinetUrl: 'https://direct.yandex.ru', cabinetLogin: 'nash-login' }; + +/** + * Поддельная страница браузера: помнит, куда ходили, и делает вид, что на ней есть + * только перечисленные элементы. Настоящий браузер для этих проверок не нужен. + */ +function fakePage({ landsOn = 'https://direct.yandex.ru/wizard/overview/', present = [] } = {}) { + const visited = []; + + return { + visited, + async goto(url) { visited.push(url); }, + url: () => landsOn, + locator: (selector) => ({ + async waitFor() { + if (!present.includes(selector)) throw new Error(`нет элемента ${selector}`); + }, + }), + }; +} + +test('вход жив, когда виден блок баланса', async () => { + const page = fakePage({ present: ['[data-testid="Sidebar.BalanceExpanded"]'] }); + + assert.equal(await isLoggedIn(page, config), true); + assert.equal(page.visited[0], 'https://direct.yandex.ru/wizard/overview/?ulogin=nash-login'); +}); + +test('вход жив, если баланс скрыт, но видна карточка пользователя', async () => { + const page = fakePage({ present: ['[data-testid="SidebarUserControl"]'] }); + + assert.equal(await isLoggedIn(page, config), true); +}); + +test('вход слетел, когда кабинет перебросил на страницу входа', async () => { + const page = fakePage({ + landsOn: 'https://passport.yandex.ru/auth?retpath=...', + present: ['[data-testid="Sidebar.BalanceExpanded"]'], + }); + + assert.equal(await isLoggedIn(page, config), false); +}); + +test('вход слетел, когда страница отдалась, но кабинета на ней нет', async () => { + assert.equal(await isLoggedIn(fakePage({ present: [] }), config), false); +}); + +test('не падает, если страница вообще не открылась', async () => { + const page = fakePage(); + page.goto = async () => { throw new Error('сеть недоступна'); }; + + assert.equal(await isLoggedIn(page, config), false); +}); diff --git a/cspell-words.txt b/cspell-words.txt index 38a30bf0..dbf16fc4 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -603,6 +603,7 @@ sre # Технические идентификаторы из ТЗ TIMESTAMPTZ BYPASSRLS +rolname unrecovered recalc soft @@ -1564,6 +1565,7 @@ GAAP парсятся ретрай субледжер +леджер хардкодит # finance-tooling C6+C7 — billing-audit skill (2026-05-20) @@ -2405,6 +2407,7 @@ avtopodbor скоупы харднинг яндексовом +яндексовой # Транслитерация путей спек/планов прогрева (v8.83, раздельные сроки по площадкам) razdelnye @@ -2477,3 +2480,38 @@ svyazka слагов схлопывает фирмо +reklamnyy +koshelek +chast +dlya +klientov +kanal +Директа +яндексовы +поллинг +reklama +otkazy +okno +peredachi +pokazy +medijnaya +konstruktor +ulogin +moderacii +otkaz +otkaza +razvernuto +zakryt +reklama +otkazy +okno +peredachi +pokazy +medijnaya +konstruktor +ulogin +moderacii +otkaz +otkaza +razvernuto +zakryt diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index f52bf398..a08bb84e 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -1,17 +1,16 @@ # CHANGELOG schema.sql — Лидерра -**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. +**Назначение:** консолидированный журнал изменений `schema.sql`. Записи идут в обратном хронологическом порядке — новая сверху, как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.85, консолидированный DDL) +**Файл схемы:** `schema.sql` — консолидированный DDL. **Версия схемы и метрики (таблицы / партиции / индексы / RLS-политики / функции / триггеры) — канон в header самого `schema.sql`**, здесь они не дублируются: файл ведут параллельные ветки, и любое число, вписанное сюда, устаревает молча. Прежняя шапка держала счётчик «тридцать записей» — их давно больше; счётчик убран, чтобы не врать. Номер версии (v8.85) со `schema.sql` совпадал и убран не как ошибочный, а чтобы не поддерживать его в двух местах. -> **Перенумерация (14.07.2026):** записи ниже (v8.67–v8.70) сделаны на ветке `feat/sales-finder` -> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), -> поэтому при сведении они перенумерованы. Содержание не менялось. +⚠️ **Рекламный и телеграмный модули в `schema.sql` не консолидированы.** Записи v9.x ведут таблицы `ad_*` и `client_tg_*`, которых в теле `schema.sql` нет вовсе — их DDL живёт только в дельта-миграциях. Идти за версией v9.x в header `schema.sql` бесполезно: там своя нумерация v8.x (на 29.07.2026 — v8.85, и слияние телеграма её не двигает). Правка шапки от 29.07.2026, к содержанию записей отношения не имеет. -## v8.95 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон) +> **Перенумерация (29.07.2026):** записи ниже (v9.18–v9.27) сделаны на ветке `feat/client-telegram-ads` +> параллельно с боевым main. Их прежние номера (v8.86–v8.95) **столкнулись** с боевыми (рекламный +> кошелёк и Яндекс-канал), поэтому при сведении они перенумерованы. Содержание не менялось. -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.94). Номер **v8.95 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.27 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон) Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки сообщений по номеру, отписок и шаблонов сообщений (владельцем подтверждено). Эти сущности были механически @@ -19,12 +18,12 @@ - **Дроп таблиц** (миграция `app/database/migrations/2026_07_28_000017_drop_client_tg_sms_legacy_tables.php`, `DROP TABLE IF EXISTS … CASCADE`): - - `client_tg_senders` (было v8.87) — «своё имя/бренд отправителя» + помесячная абонплата; - - `client_tg_settings` (было v8.86) — обе колонки (`name_fee_rub_per_month`, `name_debt_grace_days`) + - `client_tg_senders` (было v9.19) — «своё имя/бренд отправителя» + помесячная абонплата; + - `client_tg_settings` (было v9.18) — обе колонки (`name_fee_rub_per_month`, `name_debt_grace_days`) обслуживали только имя; - - `client_tg_messages` (было v8.86) — «по-получательские записи доставки» (копия `sales_sms_messages`), + - `client_tg_messages` (было v9.18) — «по-получательские записи доставки» (копия `sales_sms_messages`), без Eloquent-модели, в рабочем коде не использовалась; - - `client_tg_templates` (было v8.86) — «шаблоны сообщений», не использовались нигде. + - `client_tg_templates` (было v9.18) — «шаблоны сообщений», не использовались нигде. Их **create-миграции удалены** (000003/000006/000008/000010) — на свежей базе таблицы не создаются, DROP — no-op; на уже мигрированных dev/prod убирает. - **CHECK `balance_transactions_type_check` −= `tg_name_fee`** — тип был зарезервирован под отменённую @@ -41,11 +40,9 @@ --- -## v8.94 (2026-07-28) — Telegram-деньги: CHECK balance_transactions.type += tg_ad_charge/tg_ad_refund +## v9.26 (2026-07-28) — Telegram-деньги: CHECK balance_transactions.type += tg_ad_charge/tg_ad_refund -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.93). Номер **v8.94 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. -> NB: изначально запись добавляла и `tg_name_fee`, но он убран в v8.95 (плата за имя — отменённая фантазия). +> NB: изначально запись добавляла и `tg_name_fee`, но он убран в v9.27 (плата за имя — отменённая фантазия). Кусок 1 денежной модели Telegram-рекламы (спека `docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md`): реклама оплачивается с @@ -73,19 +70,16 @@ --- -## v8.93 (2026-07-28) — Telegram-модуль, ревью-фикс F9: SELECT служебным ролям на client_tg_campaigns - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.92). Номер **v8.93 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.25 (2026-07-28) — Telegram-модуль, ревью-фикс F9: SELECT служебным ролям на client_tg_campaigns Правка после сводного код-ревью ветки (находка API I4 / капкан `srv_bypass`). Только GRANT — таблиц/колонок/политик не добавляет. - **GRANT SELECT ON client_tg_campaigns → crm_supplier_worker, crm_admin_user** (role-guarded `IF EXISTS`). `PollTelegramModerationJob` и `SweepStuckTelegramCampaignsJob` перечисляют кампании **кросс-тенантно** - через `pgsql_supplier` (= `crm_supplier_worker` на проде). Create-миграция (000001, v8.86) выдала GRANT + через `pgsql_supplier` (= `crm_supplier_worker` на проде). Create-миграция (000001, v9.18) выдала GRANT только `crm_app_user` → на боевом кластере (служебные роли **НЕ BYPASSRLS**) джобы получили бы 0 строк (тихий ноль: зависшие кампании не добьются, модерация не опросится). Даём явный SELECT — как уже сделано - для `client_tg_senders` (v8.87 / миграция 000010). Миграция + для `client_tg_senders` (v9.19 / миграция 000010). Миграция `app/database/migrations/2026_07_28_000016_grant_service_roles_client_tg_campaigns.php`. - Заодно исправлен **ложный комментарий** в миграции 000011 (утверждал «GRANT-ы 5 ролям уже заданы» — на деле create-миграция дала только `crm_app_user`). @@ -102,10 +96,7 @@ --- -## v8.92 (2026-07-28) — Telegram-модуль, Этап 5.4: клиентский порог пачки авторассылки (client_tg_auto_rule.batch_threshold) - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.91). Номер **v8.92 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.24 (2026-07-28) — Telegram-модуль, Этап 5.4: клиентский порог пачки авторассылки (client_tg_auto_rule.batch_threshold) Клиентский порог пачки авторассылки (Этап 5, задача 5.4 плана `docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). @@ -118,17 +109,14 @@ Миграция `app/database/migrations/2026_07_28_000015_add_batch_threshold_to_client_tg_auto_rules.php`. **RLS:** не меняется — колонка на существующей таблице `client_tg_auto_rule`. Политика `tenant_isolation` -и GRANT (`SELECT, INSERT, UPDATE` для `crm_app_user`) заданы миграцией `_create_client_tg_auto_rule` (v8.86). +и GRANT (`SELECT, INSERT, UPDATE` для `crm_app_user`) заданы миграцией `_create_client_tg_auto_rule` (v9.18). Табличный GRANT в PostgreSQL распространяется на новые колонки — ре-GRANT не нужен. Новых политик/GRANT-ов нет. **rls-reviewer:** прогнан на миграции — чисто, RLS-рисков нет (nullable-колонка данных на tenant-скоуп-таблице, tenant_id и политика без изменений). --- -## v8.91 (2026-07-28) — Telegram-модуль, Этап 5.1: предохранители авторассылки (client_tg_auto_rule.daily_limit_rub + счётчик за день) - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.90). Номер **v8.91 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.23 (2026-07-28) — Telegram-модуль, Этап 5.1: предохранители авторассылки (client_tg_auto_rule.daily_limit_rub + счётчик за день) Дневной лимит и счётчик трат авто-рассылки (Этап 5, задача 5.1 плана `docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). @@ -148,17 +136,14 @@ `app/database/migrations/2026_07_28_000014_add_daily_limit_to_client_tg_auto_rules.php`. **RLS:** не меняется — три колонки на существующей таблице `client_tg_auto_rule`. Политика `tenant_isolation` -и GRANT (`SELECT, INSERT, UPDATE` для `crm_app_user`) заданы миграцией `_create_client_tg_auto_rule` (v8.86). +и GRANT (`SELECT, INSERT, UPDATE` для `crm_app_user`) заданы миграцией `_create_client_tg_auto_rule` (v9.18). Табличный GRANT в PostgreSQL распространяется на новые колонки — ре-GRANT не нужен. Новых политик/GRANT-ов нет. **rls-reviewer:** прогнан на миграции — чисто, RLS-рисков нет (только колонки данных на tenant-скоуп-таблице, tenant_id и политика без изменений). --- -## v8.90 (2026-07-28) — Telegram-модуль, Этап 3.6: документ модератору для пересдачи (client_tg_campaigns.moderator_file_path) - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.89). Номер **v8.90 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.22 (2026-07-28) — Telegram-модуль, Этап 3.6: документ модератору для пересдачи (client_tg_campaigns.moderator_file_path) Пересдача отклонённой модерацией кампании (Этап 3, задача 3.6 плана `docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). @@ -171,7 +156,7 @@ `app/database/migrations/2026_07_28_000013_add_moderator_file_path_to_client_tg_campaigns.php`. **RLS:** не меняется — колонка на существующей таблице `client_tg_campaigns`. Политика `tenant_isolation` -задана миграцией `_create_client_tg_campaigns` (v8.86); GRANT-ы покрыты для таблицы (`crm_app_user` — в той +задана миграцией `_create_client_tg_campaigns` (v9.18); GRANT-ы покрыты для таблицы (`crm_app_user` — в той же миграции, остальные 4 роли — глобально `db/02_grants.sql` + `03_service_bypass_policies.sql`). Табличный GRANT в PostgreSQL распространяется и на новые колонки — ре-GRANT не нужен. Новых политик/GRANT-ов нет. @@ -179,10 +164,7 @@ GRANT в PostgreSQL распространяется и на новые коло --- -## v8.89 (2026-07-28) — Telegram-модуль, Этап 3.1: id кампании МТС (client_tg_campaigns.mts_campaign_id) - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86–v8.88). Номер **v8.89 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.21 (2026-07-28) — Telegram-модуль, Этап 3.1: id кампании МТС (client_tg_campaigns.mts_campaign_id) Раннее и надёжное сохранение id кампании МТС (Этап 3, задача 3.1 плана `docs/superpowers/plans/2026-07-27-telegram-module-hardening.md`). @@ -196,16 +178,13 @@ GRANT в PostgreSQL распространяется и на новые коло `app/database/migrations/2026_07_28_000012_add_mts_campaign_id_to_client_tg_campaigns.php`. **RLS:** не меняется — колонка на существующей таблице `client_tg_campaigns`; политика `tenant_isolation` -и GRANT-ы 5 ролям заданы миграцией `_create_client_tg_campaigns` (v8.86). Новых политик/GRANT-ов нет. +и GRANT-ы 5 ролям заданы миграцией `_create_client_tg_campaigns` (v9.18). Новых политик/GRANT-ов нет. **rls-reviewer:** не требуется (нет изменения политик/ролей/tenant-колонок — только nullable-колонка данных). --- -## v8.88 (2026-07-27) — Telegram-модуль, Сессия 5.2: причина отказа (client_tg_campaigns.status_reason) - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86/v8.87). Номер **v8.88 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.20 (2026-07-27) — Telegram-модуль, Сессия 5.2: причина отказа (client_tg_campaigns.status_reason) Обратная связь по отказу (Сессия 5, задача 5.2 плана `docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md`). @@ -217,16 +196,13 @@ GRANT в PostgreSQL распространяется и на новые коло `app/database/migrations/2026_07_27_000011_add_status_reason_to_client_tg_campaigns.php`. **RLS:** не меняется — колонка на существующей таблице `client_tg_campaigns`; политика `tenant_isolation` -и GRANT-ы 5 ролям заданы миграцией `_create_client_tg_campaigns` (v8.86). Новых политик/GRANT-ов нет. +и GRANT-ы 5 ролям заданы миграцией `_create_client_tg_campaigns` (v9.18). Новых политик/GRANT-ов нет. **rls-reviewer:** не требуется (нет изменения политик/ролей/tenant-колонок — только nullable-колонка данных). --- -## v8.87 (2026-07-27) — Telegram-модуль, Сессия 4: авто-режим + своё имя (client_tg_auto_rule, client_tg_senders) - -> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86). Номер **v8.87 предварительный** — -> при сведении с боевым main будет перенумерован. Содержание не меняется. +## v9.19 (2026-07-27) — Telegram-модуль, Сессия 4: авто-режим + своё имя (client_tg_auto_rule, client_tg_senders) Продолжение клиентского модуля Telegram-рекламы (Сессия 4 плана `docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md`), задачи 4.1 (авто-режим) и 4.3 (своё имя). @@ -256,11 +232,7 @@ GRANT в PostgreSQL распространяется и на новые коло **🔴 При выкате на кластер** (Сессия 6.3): ПЕРЕзапустить `db/03_service_bypass_policies.sql` для новых RLS-таблиц Сессии 4 (иначе «тихий ноль» служебным ролям). -## v8.86 (2026-07-27) — Клиентский модуль «Реклама в Телеграме»: ядро таблиц client_tg_* - -> ⚠️ **Ветка `feat/client-telegram-ads`** (создана от stale-кончика `fix/warming-existing-manager-dedup`, -> отстаёт от боевого main). Номер **v8.86 предварительный** — при сведении с main (где схема уже ушла -> вперёд) запись будет перенумерована, как v8.67–v8.70 в шапке. Содержание при этом не меняется. +## v9.18 (2026-07-27) — Клиентский модуль «Реклама в Телеграме»: ядро таблиц client_tg_* Бэкенд-ядро клиентского модуля Telegram-рекламы «по своей базе» — зеркало `client_sms_*` (Сессия 1 плана `docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md`). 8 новых таблиц, @@ -287,7 +259,1257 @@ rls-reviewer: **PASS 8/8**. — новые RLS-таблицы иначе дадут служебным ролям «тихий ноль». Справочные (tariffs/settings) без RLS — srv_bypass их намеренно не трогает. -Схема `schema.sql` этими миграциями пока **не переконсолидирована** (сведётся при merge в main). +Схема `schema.sql` этими миграциями **не переконсолидирована** — как и рекламные `ad_*`, таблицы +`client_tg_*` живут только в дельта-миграциях (см. врезку в шапке файла). + +## v9.17 — 28.07.2026 — служебная роль получает право ПИСАТЬ в ленту: INSERT + нумератор + +```sql +GRANT INSERT ON ad_campaign_messages TO crm_admin_user; +GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_admin_user; +``` + +Миграция: `app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php`. + +🔴 **Без этой записи разведка на бою уронила бы очередь целиком.** Доклад робота приходит +на `POST /api/creative-robot/jobs/{id}/inspection`; маршруты робота идут через посредник +`admin-db`, который подменяет подключение по умолчанию на `pgsql_admin` = `crm_admin_user`. +Значит INSERT в ленту уходит под этой ролью, а записью v9.14 ей выдали **только SELECT**. +На бою доклад упал бы с «permission denied for table ad_campaign_messages», робот получил +бы 500, трижды повторил и сдался. **Задание осталось бы навсегда «в работе», а пока хоть +одно задание в работе, выдача отвечает «работы нет» ВСЕМ клиентам.** + +Нумератор — отдельный объект: GRANT INSERT на таблицу НЕ даёт права взять следующий номер. +Общий `GRANT ... ON ALL SEQUENCES` из `db/02_grants.sql` разовый и покрыл только те +нумераторы, что существовали при создании ролей; `ad_campaign_messages_id_seq` появился +позже. Тот же класс ошибки уже ловили в v9.05. + +🪤 На dev/тестах дырка невидима — там ходит суперпользователь `postgres`, которому права +не проверяются. Поймал её не тест, а агент `rls-reviewer` по коду. UPDATE и DELETE +не выдаём: лента только пополняется. + +## v9.16 — 28.07.2026 — какое объявление Яндекса смотрит разведка: ad_creative_jobs.yandex_ad_id + +```sql +ALTER TABLE ad_creative_jobs ADD COLUMN yandex_ad_id BIGINT NULL; +CREATE INDEX idx_creative_job_inspect ON ad_creative_jobs (campaign_id, kind, yandex_ad_id); +``` + +Миграция: `app/database/migrations/2026_07_29_110000_add_yandex_ad_id_to_ad_creative_jobs.php`. + +**Зачем.** Задача 15 «разведка»: программный интерфейс Яндекса причину отказа не отдаёт — +на отклонённое объявление `ads.get` возвращает «Отклонено на модерации.» и всё (проверено +живьём боевым ключом 28.07.2026). Причину видит только робот на экране кабинета, и ему +нужен номер объявления, которое он идёт смотреть. + +🔴 **Почему колонка, а не «просто задание на кампанию».** Опрос модерации бежит по +расписанию, а отказ никуда не девается: объявление остаётся отклонённым, пока клиент его +не починит. Дедуп «по кампании среди незавершённых» не спасает — первая разведка +закрывается за минуту, и следующий обход поставил бы вторую, третью, и так по кругу. +Номер объявления — естественный ключ: он не меняется, пока не меняется сама беда, а после +кнопки «Исправить» объявление заводится заново и получает новый номер, то есть новый отказ +честно получит свою разведку. + +**Внешнего ключа нет и быть не может** — это номер в чужой системе, такой таблицы у нас +не существует. Баннер по нему находится связью ОТ КАМПАНИИ +(`$campaign->banners()->where('yandex_ad_id', ...)`), чужой так не подцепится. + +Гранты не нужны — права на таблицу уже выданы, колоночных GRANT'ов на `ad_creative_jobs` +нет. Перезапуск `db/03_service_bypass_policies.sql` **не требуется**: новых таблиц запись +не создаёт. Частичный уникальный индекс `uq_creative_job_single_taken` не затронут — новый +индекс обычный и по другим колонкам. + +Дата в имени файла миграции — `2026_07_29`, хотя запись сделана 28.07: имя держит порядок +применения рядом с соседними `2026_07_29_*` из того же куска работы. + +## v9.15 — 29.07.2026 — клиент задания и сообщения обязан совпадать с клиентом кампании + +```sql +ALTER TABLE ad_campaigns ADD CONSTRAINT uq_ad_campaign_tenant UNIQUE (id, tenant_id); + +ALTER TABLE ad_creative_jobs DROP CONSTRAINT ad_creative_jobs_campaign_id_foreign; +ALTER TABLE ad_creative_jobs + ADD CONSTRAINT fk_creative_job_campaign_tenant + FOREIGN KEY (campaign_id, tenant_id) REFERENCES ad_campaigns (id, tenant_id) ON DELETE CASCADE; + +ALTER TABLE ad_campaign_messages DROP CONSTRAINT ad_campaign_messages_campaign_id_foreign; +ALTER TABLE ad_campaign_messages + ADD CONSTRAINT fk_ad_campaign_message_campaign_tenant + FOREIGN KEY (campaign_id, tenant_id) REFERENCES ad_campaigns (id, tenant_id) ON DELETE CASCADE; +``` + +**Зачем.** Замыкание v9.13. Та запись привязала документ к кампании — главное, «бумага +клиента A в кабинет клиента B», закрылось. Но `campaign_id` в обеих таблицах ссылался +на кампанию **без клиента**, и база по-прежнему принимала строку, где задание числится +за клиентом A, а кампания принадлежит клиенту B. Гарантия v9.13 держалась на неявном +допущении, которого база не проверяла. Найдено повторной проверкой прав доступа, +воспроизведено вживую до правки. + +**Живого пути к такой строке не было:** `CreativeJobService::enqueue()` берёт клиента +и кампанию из одной модели, сообщения создаются только от загруженной кампании. Но задача 16 +заводит задания на служебном канале, где RLS не отфильтрует, — допущение осталось бы висеть +на аккуратности кода. + +**Побочный выигрыш — закрыт тихий боковой канал.** Задание с чужим `tenant_id` было видно +клиенту A по RLS, а робот пишет в него причину провала: попади туда слова модератора чужой +кампании — капля данных B в портал A. + +`ON DELETE CASCADE` сохранён прежний: удаление кампании уносит её задания и её ленту. +Проверено вверх-вниз-вверх на `liderra_testing_reklama`; `down()` возвращает простые ключи +под прежними именами, иначе откат исходных миграций не нашёл бы, что удалять. + +**RLS/GRANT.** Не трогаем: ограничения целостности прав не требуют. + +**Миграция:** `app/database/migrations/2026_07_29_100300_guard_ad_campaign_children_belong_to_same_tenant.php`. + +## v9.14 — 29.07.2026 — админская роль получает чтение ленты: GRANT SELECT ON ad_campaign_messages + +```sql +-- в миграции — под гардом на pg_roles, откат симметричным REVOKE +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT ON ad_campaign_messages TO crm_admin_user; + END IF; +END +$$; +``` + +**Зачем.** Таблица создана 28.07 (v9.10), гранты получили только `crm_app_user` и +`crm_supplier_worker`. Под `crm_admin_user` работают два будущих читателя: служебный канал +робота, который заберёт документ клиента (задача 16), и админский экран «ждёт разбора». +Без гранта оба увидели бы ноль строк **молча** — тот же класс поломки, что уже ловили +на `ad_campaign_banners` (v9.02). + +**Только SELECT.** Сообщения создаёт портал и джоб опроса модерации; служебному каналу +писать в ленту незачем. Тот же принцип наименьших прав, что у `ad_creative_jobs` (v9.06), +где роботу сознательно не дали INSERT. + +🔴 **Перезапуск `03_service_bypass_policies.sql` в этом выкате ОБЯЗАТЕЛЕН** — но не из-за +этой записи, а из-за новых таблиц `ad_campaign_messages` (v9.10) и `ad_creative_jobs` (v9.06). +Политику `srv_bypass` не ставит ни `CREATE TABLE`, ни миграция: только ручной прогон файла, +который перебирает таблицы с включённым RLS. Без него служебные роли увидят в обеих таблицах +ноль строк молча, при зелёном журнале. Первая редакция этой записи утверждала обратное — +исправлено повторной проверкой прав доступа 29.07. + +🔴 **Выкатывать только вместе с v9.13 и v9.15.** Грант открывает роли чтение ленты целиком; +тем, что робот не возьмёт чужой документ, занимаются составные ключи из v9.13 и v9.15. +Порознь эти записи разносить нельзя. + +**Миграция:** `app/database/migrations/2026_07_29_100200_grant_admin_ad_campaign_messages.php`. + +## v9.13 — 29.07.2026 — документ обязан принадлежать своей кампании: составной внешний ключ + +```sql +ALTER TABLE ad_campaign_messages + ADD CONSTRAINT uq_ad_campaign_message_campaign UNIQUE (id, campaign_id); + +ALTER TABLE ad_creative_jobs DROP CONSTRAINT ad_creative_jobs_message_id_foreign; + +ALTER TABLE ad_creative_jobs + ADD CONSTRAINT fk_creative_job_message_campaign + FOREIGN KEY (message_id, campaign_id) + REFERENCES ad_campaign_messages (id, campaign_id) + ON DELETE SET NULL (message_id); +``` + +**Зачем.** Простой внешний ключ из v9.12 не защищал от чужого клиента: проверки ссылочной +целостности в PostgreSQL выполняются **в обход RLS** и подтверждают только, что строка +существует. В портале дыра не выстрелила бы — там RLS, чужого сообщения клиент не увидит. +Но робот ходит под `crm_admin_user`, которому `db/03_service_bypass_policies.sql` даёт +кросс-тенантный доступ: он молча увёз бы документ ОДНОГО клиента в модерацию кампании +ДРУГОГО. Утечка персональных данных без единой ошибки в журнале, при зелёных тестах. + +Дыру нашла проверка прав доступа по v9.12; тесты её увидеть не могли. Воспроизведена +вживую: до этой миграции задание спокойно принимало чужой документ. + +**Ключ по кампании, а не по тенанту.** Ключом по одному тенанту не ловился бы второй случай — +другая кампания того же клиента, где документ всё равно не тот. Ключ по кампании ловит оба, +**но только вместе с v9.15**: сама по себе эта запись гарантирует, что документ принадлежит +кампании задания, и ничего не говорит о том, что клиент задания совпадает с владельцем этой +кампании. Первая редакция записи утверждала «закрывает оба случая разом» — это было сильнее +гарантии, поймано повторной проверкой прав доступа и замкнуто записью v9.15. + +Проверка в коде задачи 16 остаётся обязательной как второй рубеж: сообщение брать только +связью от кампании, а не сырым номером из задания. + +🪤 `ON DELETE SET NULL (message_id)` — точечный вариант, гасит только ссылку на сообщение. +Обычный `ON DELETE SET NULL` у составного ключа обнулил бы и `campaign_id`, который NOT NULL, +и удаление сообщения падало бы с ошибкой. Синтаксис требует **PostgreSQL 15+**; у нас 16.13. +Laravel этого не умеет — отсюда сырой SQL вместо Blueprint. + +🪤 Правило MATCH SIMPLE (умолчание): когда `message_id` пуст, ключ не проверяется вовсе. +Все сегодняшние задания — без документа, их эта правка не касается. + +**Проверено вверх-вниз-вверх** на `liderra_testing_reklama`; `down()` возвращает простой +ключ под прежним именем `ad_creative_jobs_message_id_foreign`, иначе откат v9.12 не нашёл бы, +что удалять. + +**RLS/GRANT.** Не трогаем: ограничения целостности прав не требуют. + +**Миграция:** `app/database/migrations/2026_07_29_100100_guard_creative_job_message_belongs_to_campaign.php`. + +## v9.12 — 29.07.2026 — вид задания роботу: ad_creative_jobs.kind + message_id + +```sql +ALTER TABLE ad_creative_jobs ADD COLUMN kind varchar(16) NOT NULL DEFAULT 'upload'; +ALTER TABLE ad_creative_jobs ADD COLUMN message_id bigint NULL + REFERENCES ad_campaign_messages(id) ON DELETE SET NULL; +``` + +**Зачем.** До сих пор робот-грузчик умел ровно одно — отвезти картинки в веб-кабинет +Яндекса, и сама очередь молчаливо означала именно это. Для окна передачи отказов ему +нужны ещё два умения: сходить посмотреть, что кабинет говорит про объявление, и отвезти +документ клиента с отправкой на повторную модерацию. Вид — явная пометка на задании, +а не догадка по его содержимому. + +Виды: `upload` — отвезти картинки, `inspect` — только посмотреть, `deliver` — отвезти +документ и отправить на модерацию. + +**Умолчание обязательно.** Задания, уже лежащие в очереди на момент выката, вида не имеют. +Без `DEFAULT 'upload'` робот получил бы пустое поле и не понял, что делать, — тот же приём, +что у `status` в исходной таблице (v9.06). + +**Почему ссылка на сообщение, а не только на кампанию.** У кампании документов может быть +несколько, и робот, знающий только номер кампании, повезёт не тот. `ON DELETE SET NULL` +выбран сознательно: при удалении сообщения задание остаётся и честно провалится по +«документа нет», вместо того чтобы уронить очередь ошибкой целостности. + +**RLS/GRANT.** Не трогаем: колонки добавлены в существующую таблицу, политика +`tenant_isolation` и права (`crm_app_user` — SELECT/INSERT/UPDATE, `crm_admin_user` — +SELECT/UPDATE) выданы на уровне таблицы и продолжают действовать. Частичный уникальный +индекс `uq_creative_job_single_taken` (v9.08) остаётся как есть: у робота один браузерный +профиль, задания идут по одному, новые виды этого не меняют. + +🔴 **Два обязательных условия задачи 16 — записаны здесь, чтобы не потерялись.** + +1. ✅ **Грант — выдан записью v9.14.** Роль `crm_admin_user`, под которой работает служебный + канал робота, не имела `GRANT SELECT` на `ad_campaign_messages`: миграция работала и без + него (ключ проверяет система), но робот, начав читать сообщение, увидел бы ноль строк + молча. +2. **Проверка принадлежности — теперь и в базе тоже, см. v9.13.** Проверки ссылочной целостности + в PostgreSQL выполняются в обход RLS: внешний ключ подтвердит только существование + строки, а не то, что сообщение принадлежит тому же клиенту и той же кампании. Портал + чужое сообщение не покажет (там RLS), но робот ходит под `crm_admin_user`, которому + `db/03_service_bypass_policies.sql` даёт кросс-тенантный доступ. Итог без проверки — + робот молча увозит документ ОДНОГО клиента в модерацию кампании ДРУГОГО. Это утечка + персональных данных, а не косметика. Сообщение брать только связью от кампании + (`$campaign->messages()->findOrFail($id)`), никогда — сырым номером из запроса. + +✅ **Закрыто в тот же день записью v9.13** — составным внешним ключом по кампании. +Проверка в коде остаётся обязательной как второй рубеж, но теперь она не единственная: +чужой документ отказывает сама база. Первоначальная оценка «сознательно оставляем на +дисциплине кода» снята — точечный `ON DELETE SET NULL (message_id)` PostgreSQL 16 умеет, +и сырого SQL на одну миграцию оказалось достаточно. + +**Индекса на `message_id` нет** — при удалении сообщения PostgreSQL просматривает +`ad_creative_jobs` целиком. Таблица-очередь мелкая, сейчас это ничего не стоит; если +очередь вырастет — добавить. + +**Миграция:** `app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php`. + +## v9.11 — 28.07.2026 — отметка «отдана клиенту на починку»: ad_campaigns.revived_at + +```sql +ALTER TABLE ad_campaigns ADD COLUMN revived_at timestamptz NULL; +-- в миграции стоит ->after('launched_at'), но на PostgreSQL это no-op: физически колонка встаёт в конец таблицы +``` + +**Зачем.** Правка кампании заперта признаком «в Яндексе уже что-то заведено», и у этого +замка есть одно узкое исключение — кампанию, которую Яндекс отклонил, клиент обязан иметь +возможность починить. Исключение было написано по статусу `rejected`, но кнопка «Исправить» +переводит кампанию в черновик — и исключение гасло в ту же секунду: мастер открывался, +а сервер не давал ни сменить адрес сайта, ни перезалить картинку. Клиент оставался +в прежнем тупике, только шагом дальше. + +Поймано живой проверкой в браузере 28.07.2026 при полностью зелёных тестах: тесты +проверяли правку у кампании, которая ещё числится отклонённой, — а кнопка это состояние +покидает немедленно. + +**Как работает.** Отметку ставит `CampaignReviveService` при оживлении, гасит +`CampaignLauncher` при следующем запуске. Замок открыт ровно на время починки. +Черновик после ОБОРВАВШЕГОСЯ запуска отметки не получает и остаётся запертым: там +сегмент и кампания в Яндексе живы, а объявлений может не быть вовсе — правка настроек +разошлась бы с кабинетом молча. + +**RLS/GRANT.** Не трогаем: колонка добавлена в существующую таблицу, политики и права +выданы на уровне таблицы и продолжают действовать. + +**Миграция:** `app/database/migrations/2026_07_28_110000_add_revived_at_to_ad_campaigns.php`. + +## v9.10 — 28.07.2026 — лента сообщений по рекламной кампании: ad_campaign_messages + +Новая таблица — окно передачи между Яндексом и клиентом по отказам модерации. + +```sql +CREATE TABLE ad_campaign_messages ( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL REFERENCES tenants (id) ON DELETE CASCADE, + campaign_id bigint NOT NULL REFERENCES ad_campaigns (id) ON DELETE CASCADE, + banner_id bigint NULL REFERENCES ad_campaign_banners (id) ON DELETE SET NULL, + author varchar(16) NOT NULL, -- yandex | client | system + body text NOT NULL, + file_path varchar(512) NULL, + file_name varchar(255) NULL, + file_size integer NULL, + file_mime varchar(128) NULL, + created_at timestamp NULL, + updated_at timestamp NULL +); +CREATE INDEX ad_campaign_messages_tenant_id_campaign_id_id_index + ON ad_campaign_messages (tenant_id, campaign_id, id); +``` + +**Зачем.** Пояснение модератора Яндекса портал исправно сохранял и не показывал никому: +экран отчёта по кампании читает старую таблицу `ad_campaign_ads`, которую поток «за показы» +не заполняет вообще. Клиент видел красный ярлык «Отклонено» без единого слова объяснения. +Лента — одно место, через которое проходят и слова Яндекса, и ответ клиента, и приложенные +им документы. + +**Почему `body` — `text`, а не `varchar(255)`.** Модератор перечисляет претензии списком. +Именно предел колонки `moderation_reason` на 255 знаков уже ронял обход модерации целиком +(починено 28.07.2026): длинное пояснение не влезало, падал весь джоб, и вместе с ним — +вердикты по чужим кампаниям и возврат денег. Повторять эту мину нельзя. Обрезанное +по-прежнему живёт в `ad_campaign_banners.moderation_reason` — оно для ярлыка. + +**RLS.** `ENABLE` + `FORCE ROW LEVEL SECURITY`, политика `tenant_isolation` по +`app.current_tenant_id` — побуквенно как у `ad_creative_jobs` и `ad_campaign_banners`. + +**Гранты.** `SELECT, INSERT` двум ролям — `crm_app_user` (клиентский портал) и +`crm_supplier_worker` (под ней бежит `SyncCampaignModerationJob` через соединение +`pgsql_supplier`, он и кладёт в ленту пояснения Яндекса). `UPDATE` и `DELETE` не выданы +никому осознанно: лента только пополняется, сообщения не правятся и не стираются. +Плюс `USAGE, SELECT` на нумератор `ad_campaign_messages_id_seq` обеим ролям — без него +`INSERT` падает на бою с «permission denied for sequence», а на dev дырка невидима +(там суперпользователь). Все гранты — внутри `DO $$` с гардом на существование роли. + +`crm_admin_user` гранта **не получает**: админского экрана по ленте пока нет. Когда +появится список «ждёт разбора» (кусок 3 замысла), понадобится догоняющая миграция с +`GRANT SELECT` — по образцу `2026_07_27_100100_grant_admin_ad_campaign_banners.php`. +🪤 Без неё админский экран увидит ноль строк молча, без единой ошибки. + +🔴 **После выката на бой перезапустить `db/03_service_bypass_policies.sql`.** На боевом +кластере `crm_supplier_worker` и `crm_admin_user` не BYPASSRLS — кросс-тенантный доступ +им даёт именно этот файл. Он таблично-агностичен и новую таблицу подхватит сам, но без +повторного прогона джоб модерации увидит в ленте ноль строк, а журнал будет зелёный. + +**Миграция:** `app/database/migrations/2026_07_28_100000_create_ad_campaign_messages.php`. +**Замысел:** `docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md`. + +## v9.09 — 27.07.2026 — один баннер на слот кампании: uq_ad_campaign_banner_slot + +Уникальный индекс на `ad_campaign_banners`: + +```sql +CREATE UNIQUE INDEX uq_ad_campaign_banner_slot + ON ad_campaign_banners (tenant_id, campaign_id, width, height); +``` + +**Зачем.** Загрузка картинки в слот идёт через `updateOrCreate` +(`AdvertisingCampaignController::uploadBanner()`), а он без уникального индекса **не +атомарен**: два одновременных нажатия «загрузить» на один и тот же размер (двойной клик, +повтор с телефона, медленная сеть) оба проходят проверку «такой строки ещё нет» и создают +две строки одного слота. + +Последствие тихое и дорогое. Список слотов клиенту собирается через `keyBy` по размеру и +одну строку молча теряет — в портале виден один баннер. А `CampaignLauncher` идёт по **всем** +строкам набора и создаёт **два одинаковых объявления**, которые крутятся за деньги клиента. +Раньше на таблице был только обычный индекс `(tenant_id, campaign_id)` — от дублей он +не защищает. + +**Форма ключа.** `tenant_id` в ключе избыточен логически (он однозначно определяется +кампанией), но все запросы к таблице идут с явным `tenant_id` поверх RLS, и индекс той же +формы работает заодно как рабочий индекс выборки. + +**Миграция строгая, дубли не чистит.** Если бы в таблице уже лежали дубли, миграция упала бы +громко — это осознанно: молча удалять картинки клиента нельзя. На боевом сервере таблицы +`ad_campaign_banners` ещё нет вовсе (проверено 27.07.2026), так что чистить нечего. + +**RLS/гранты.** Не затронуты: индекс — не объект, которому выдают права. Политика +`tenant_isolation` и гранты из миграции `2026_07_26_100200` действуют без изменений. +`db/03_service_bypass_policies.sql` из-за этой миграции перезапускать **не нужно** — +новых таблиц нет. + +**Миграция:** `app/database/migrations/2026_07_27_130000_add_unique_slot_to_ad_campaign_banners.php`. +DDL — в дельта-миграции. + +## v9.08 — 27.07.2026 — «в работе не больше одного задания робота»: uq_creative_job_single_taken + +Частичный уникальный индекс на `ad_creative_jobs`: + +```sql +CREATE UNIQUE INDEX uq_creative_job_single_taken + ON ad_creative_jobs ((status)) + WHERE status = 'taken'; +``` + +Гарантия на уровне базы: заданий в статусе `taken` в любой момент не больше одного. + +**Зачем.** На этом инварианте держится всё опознание креативов. Номера креативов портал +добывает слепками `creatives.get` по аккаунту целиком — «до» и «после» загрузки, — а робот +номера не читает вовсе. Два задания в работе одновременно перемешивают слепки между +кампаниями; размеры блоков у всех клиентов одинаковые (IAB: 300×250, 728×90, …), поэтому +итог — не громкий отказ, а **тихая привязка чужого номера**: картинка одного клиента уезжает +в объявление другого. + +Кода для этого не хватало. В `CreativeJobService::takeNext()` стояла проверка +`AdCreativeJob::where('status', 'taken')->exists()`, но она не блокирует строку. Две +одновременные транзакции обе видят «в работе никого»; первая берёт задание #1 и коммитит, +вторая упирается в замок строки #1, после коммита PostgreSQL (READ COMMITTED) перепроверяет +условие `status = 'queued'`, строка #1 уже не подходит — и вторая забирает задание #2. Оба +оказываются `taken`. `lockForUpdate()` защищал от выдачи одного задания дважды, но не от +выдачи двух заданий сразу. + +**Индекс глобальный, без `tenant_id` — намеренно.** Рекламный кабинет Яндекса один на всех +клиентов, и робот-грузчик один; инвариант обязан быть общим для всей базы, иначе слепки +перемешаются именно между тенантами. + +**Почему это не мешает RLS и не стреляет ошибкой уникальности в штатной работе.** В `taken` +строку переводит только служебный канал робота (`/api/creative-robot/*`, посредник `admin-db`, +роль `crm_admin_user` с разрешающей политикой `srv_bypass` — то есть он и так видит все +тенанты). Клиентский портал под `crm_app_user` только **создаёт** задание в статусе `queued` +и в `taken` его не переводит. Плюс в `takeNext()` первой строкой транзакции стоит +`pg_advisory_xact_lock(hashtext('ad_creative_jobs_take'))` — штатный путь не упирается в +нарушение индекса, а спокойно отвечает роботу «работы нет». + +**RLS/гранты.** Не затронуты: индекс — не объект, которому выдают права. Политика +`tenant_isolation` и табличные гранты из v9.06 действуют без изменений. +`db/03_service_bypass_policies.sql` из-за этой миграции перезапускать **не нужно** — новых +таблиц нет. 🔴 Но при выкате ветки его перезапустить **обязательно** из-за самой таблицы +`ad_creative_jobs` (v9.06), иначе служебные роли увидят ноль. + +**Миграция:** `app/database/migrations/2026_07_27_120100_add_single_taken_guard_to_ad_creative_jobs.php`. +DDL — в дельта-миграции. + +## v9.07 — 27.07.2026 — дата последнего дня показа кампании: ad_campaigns.shows_until + +Новая колонка `ad_campaigns.shows_until` (`date`, nullable; `->after('run_days')` в миграции — +на PostgreSQL это no-op, физически колонка встаёт в конец таблицы) — последний день +показа кампании, ровно тот, что уходит в Директ параметром `EndDate` при запуске +(`CampaignLauncher::resolvePeriod`). Пишется вместе с `yandex_campaign_id`, то есть в тот +момент, когда эту дату начинает держать Яндекс; возобновляемый запуск переиспользует уже +записанную дату, чтобы портал и Директ считали срок одинаково. + +**Зачем.** Единственным переходом кампании в `completed` было «смета откручена» +(`delivered >= paid_impressions`). Задачи, закрывающей кампанию по истечении срока показа, в +расписании не было вообще. После `EndDate` показы в Директе прекращаются, `delivered` замирает, +смета не добирается — статус оставался `running` навсегда, а заморозка денег клиента — +`ACTIVE` навсегда. Для медийной кампании по списку телефонов (аудитория ограничена списком, +частота показов — настройкой) недокрут сметы это типовой исход, а не редкий случай. + +Теперь `CampaignImpressionCharger` закрывает такую кампанию **тем же выходом**, что и «смета +откручена»: у выхода №1 появилось второе условие, новых мест вызова `AdWalletService::release()` +не добавилось — их по-прежнему ровно четыре. + +**RLS/гранты.** Не затронуты: колонка добавлена в существующую таблицу, политика +`tenant_isolation` и табличные гранты продолжают действовать без изменений. Новых объектов, +которым нужны права, не появилось. + +**Миграция:** `app/database/migrations/2026_07_27_120000_add_shows_until_to_ad_campaigns.php`. +DDL — в дельта-миграции. + +## v9.06 — 27.07.2026 — очередь заданий робота-грузчика креативов: таблица ad_creative_jobs + +Новая таблица `ad_creative_jobs` — очередь заданий роботу-грузчику: отнести готовые баннеры +кампании в веб-кабинет Яндекс.Директа через браузер (API Директа картиночный креатив создать +не может). Одно задание = один набор баннеров одной кампании. Робот работает строго по одному +заданию за раз — иначе слепки `creatives.get` «до/после» перемешаются, и опознать, какой номер +креатива принадлежит какой кампании, станет невозможно. + +**Состав.** `id`, `tenant_id` (FK `tenants`, cascade), `campaign_id` (FK `ad_campaigns`, cascade), +`status` (`string(16)`, default `queued`; жизненный цикл `queued → taken → done | failed`), +`attempts` (`unsignedSmallInteger`, default 0), `snapshot_before` (`jsonb`, nullable — слепок +номеров картиночных креативов аккаунта ДО загрузки: номер → `[ширина, высота]`), +`failure_reason` (`string(1024)`, nullable), `taken_at`/`finished_at` (nullable timestamps), +`timestamps()`. Индексы: `(tenant_id, campaign_id)` и `status`. + +**RLS.** `ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY` + политика `tenant_isolation` +(`tenant_id = current_setting('app.current_tenant_id')`), тот же паттерн, что у +`ad_campaign_banners` (26.07.2026). + +**Гранты.** `crm_app_user` (клиентский портал) — `SELECT, INSERT, UPDATE`: он ставит задание при +запуске кампании (задача 10). `crm_admin_user` — только `SELECT, UPDATE`: под этой ролью работает +служебный канал робота-грузчика, потому что маршруты `/api/creative-robot/*` идут через посредник +`admin-db` (`App\Http\Middleware\UseAdminConnection`), подменяющий активное подключение на +`pgsql_admin`. Тот же порядок, что у канала «Поиск → Портал» (`routes/web.php`, группа +`api/sales/integration`). `INSERT` роли `crm_admin_user` **сознательно не выдан**: робот заданий +не создаёт, он только берёт очередное (`queued → taken`) и отчитывается о результате +(`taken → done | failed`) — least privilege, лишнего доступа не даём. `crm_supplier_worker` прав +на таблицу не получает вовсе: рекламные джобы с этой очередью не работают. + +⚠️ Роль под каналом — это не деталь оформления, а условие работоспособности: первая редакция +миграции выдала права `crm_supplier_worker`, хотя канал ходит под `crm_admin_user`. На тестах +такая ошибка невидима (там суперпользователь `postgres`, права не проверяются), а на бою канал +робота получил бы `permission denied for table ad_creative_jobs`. Тот же класс ошибки, что v9.05. + +**Нумератор.** Отдельным грантом — `GRANT USAGE, SELECT ON SEQUENCE public.ad_creative_jobs_id_seq +TO crm_app_user`, только ему, потому что `INSERT` есть только у него. Первичный ключ — `bigserial` +(`$table->id()`), то есть рядом с таблицей живёт отдельный объект-нумератор со своими правами: +`GRANT INSERT ON <таблицу>` НЕ разрешает взять следующий номер, без `USAGE` на нумераторе `INSERT` +падает на бою с `permission denied for sequence`. Ровно этот класс дырки уже ловили и разбирали в +v9.05 (семь нумераторов рекламных таблиц) — на dev/тестах она невидима, потому что там ходит +суперпользователь `postgres`, которому права не проверяются. Гарды — на роль (`pg_roles`) и на +существование самого нумератора (`pg_class.relkind = 'S'` в схеме `public`), тот же паттерн, что в +`2026_07_27_100200_grant_ad_sequences.php`. + +`down()` — `Schema::dropIfExists('ad_creative_jobs')`: вместе с таблицей уходят и политика, и +гранты на таблицу/нумератор, отдельный `REVOKE` не нужен. + +🔴 **После выката на бой перезапустить `db/03_service_bypass_policies.sql`** — на боевом кластере +служебные роли (`crm_supplier_worker`, `crm_admin_user`) не `BYPASSRLS`, и без повторного прогона +этого файла они молча увидят в новой RLS-таблице ноль строк (тихий ноль без ошибки), пока файл не +перезапущен. `schema.sql` не тронут — по тому же паттерну, что v8.99–v9.05. + +Миграция `app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php`, модель +`app/app/Models/AdCreativeJob.php`, тест +`app/tests/Feature/Advertising/AdCreativeJobModelTest.php`. + +## v9.05 — 27.07.2026 — нумераторы рекламных таблиц: GRANT USAGE, SELECT роли клиентского портала + +`GRANT USAGE, SELECT ON SEQUENCE <таблица>_id_seq TO crm_app_user` на семь нумераторов: +`ad_campaigns_id_seq`, `ad_campaign_ads_id_seq`, `ad_campaign_phones_id_seq`, +`ad_campaign_banners_id_seq`, `ad_wallets_id_seq`, `ad_wallet_transactions_id_seq`, +`ad_wallet_holds_id_seq`. + +**Зачем.** У всех этих таблиц первичный ключ — `bigserial` (`$table->id()`), то есть рядом с +таблицей живёт отдельный объект-нумератор `<таблица>_id_seq`. В PostgreSQL это самостоятельный +объект со своими правами: `GRANT INSERT ON <таблица>` разрешает вставку, но НЕ разрешает взять +следующий номер — без `USAGE` на нумераторе INSERT падает с `permission denied for sequence`. +Табличные GRANT'ы авторы миграций рекламного модуля выдавали руками +(`create_ad_campaigns:39`, `create_ad_campaign_banners:31`, `grant_supplier_worker_advertising`), +а GRANT на нумераторы не выдал никто. + +**Кому.** Нумератор нужен только тому, кто делает INSERT. По фактическому состоянию прав +(`information_schema.role_table_grants` на `liderra_testing`) INSERT на рекламные таблицы есть +ровно у одной роли — `crm_app_user` (клиентский портал). У `crm_supplier_worker` и +`crm_admin_user` только `SELECT`/`UPDATE`, им нумератор не нужен — лишнего не раздаём +(least privilege). Джобы Директа перечисляют кампании через `pgsql_supplier`, но само списание +(вставка в `ad_wallet_transactions`) идёт основным соединением под `crm_app_user` — +см. `ChargeCampaignSpendJob::handle()`. `ad_settings_id_seq` в список НЕ входит: таблица +однострочная, INSERT в неё не выдан никому. + +**Почему не поймали раньше.** На разработке и в тестах приложение подключается суперпользователем +(`postgres`), которому права не проверяются вовсе — дырка невидима при полностью зелёных тестах и +вылезает только на бою, где ходят настоящие ограниченные роли. Тот же класс ошибки уже описан в +v8.82/v8.84: «пропущенный sequence-grant = permission denied на бою, тестам на dev/суперюзере +невидимо». + +🔴 **Корневая причина — шире одной миграции.** В `db/02_grants.sql` права раздаются как +`GRANT ... ON ALL TABLES/SEQUENCES IN SCHEMA public` (разово, только по объектам, существующим на +момент запуска) плюс `ALTER DEFAULT PRIVILEGES IN SCHEMA public ...` — **без** `FOR ROLE +crm_migrator`. Из-за отсутствия `FOR ROLE` дефолтные привилегии привязаны к тому, кто запускает +файл, а не к `crm_migrator`, под которым идут миграции: всё, созданное миграциями после последнего +запуска `02_grants.sql`, дефолтных прав не наследует, и гранты приходится выдавать вручную в каждой +миграции. Системное лечение — дописать `FOR ROLE crm_migrator` в `db/02_grants.sql`; это базовый +файл прав, правится только по согласованию с владельцем — **вынесено как отдельный вопрос**, здесь +только страховка по факту. + +Аддитивно и безопасно к повторному прогону: колонок, таблиц и RLS-политик миграция не трогает, +`GRANT` идемпотентен — если право уже выдано (например, на бою `02_grants.sql` гонялся позже +создания таблиц), повторная выдача ничего не меняет, безвредный no-op. Гарды: на существование +роли (`pg_roles`) и на существование самого нумератора (`pg_class.relkind = 'S'`) — миграция может +прогоняться на окружении, где часть таблиц ещё/уже не создана. `down()` симметричен — +`REVOKE USAGE, SELECT ON SEQUENCE ... FROM crm_app_user` под теми же гардами. + +Миграция `app/database/migrations/2026_07_27_100200_grant_ad_sequences.php`, прогнана на +`liderra_testing` (`migrate` → `rollback` → `migrate` → повторный `up()`, все прогоны DONE). +Проверка фактом: `has_sequence_privilege('crm_app_user', '', 'USAGE'/'SELECT')` — по всем +семи нумераторам до миграции `false`/`false`, после `true`/`true`; `ad_settings_id_seq` и роли +`crm_supplier_worker`/`crm_admin_user` остались `false` (лишнего не выдано). Функциональная +проверка: под `SET ROLE crm_app_user` вызов `nextval('ad_campaigns_id_seq')` теперь проходит, а +контрольный `nextval('ad_settings_id_seq')` по-прежнему даёт «нет доступа к последовательности» — +то есть проверялось именно то право, которого не хватало. `schema.sql` не тронут — по тому же +паттерну, что v8.99–v9.04. + +⚠️ Прогон на `liderra_testing` **не является** доказательством состояния боевой базы: там +`02_grants.sql` вообще не гонялся (у `crm_app_user` нет прав даже на `deals` и `deals_id_seq`). +Миграция нужна именно как страховка «вслепую» — она аддитивна, и если на бою права уже есть, +прогон будет безвредным no-op. + +## v9.04 — 27.07.2026 — админке выданы права на баннеры кампаний + +`GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_admin_user`. Таблица создана 26.07.2026 — +позже точечных грантов админке (v9.01), поэтому привилегий на ней у роли не было ни одной. +Как только админ-экран начнёт читать статус модерации по баннерам (`moderation_status`, +`moderation_reason` из v9.03), он молча увидел бы пустоту — «тихий ноль» при зелёных тестах. + +`UPDATE` выдан намеренно, а не «за компанию»: оператор должен уметь вписать номер креатива +(`yandex_creative_id`) руками, когда робот-грузчик креативов не справился. Тот же аварийный +ручной путь, ради которого в v9.01 админке дали точечный `GRANT UPDATE (yandex_creative_id) +ON ad_campaigns`, — но **scope здесь сознательно шире**: точечный (колоночный) грант разрешает +писать одну названную колонку, а табличный `GRANT UPDATE ON ad_campaign_banners` технически +позволяет оператору переписать любую колонку строки, включая `path`, `tenant_id` и `campaign_id`. +Выбран табличный, потому что колоночный грант ломает обычную работу с моделью: Eloquent-`save()` +всегда тащит `updated_at`, а колоночный грант её не разрешает — приходится обновлять сырым +query-builder'ом. Эта ловушка уже задокументирована в докблоке метода +`setCampaignCreative` (`app/app/Http/Controllers/Api/AdminAdvertisingController.php:166–184`): +«обновляем строго через query-builder (не Eloquent `save()`), иначе Eloquent попытается тронуть +`updated_at`/другие колонки и упадёт по гранту». + +Аддитивно: колонок не добавлено, RLS-политика `tenant_isolation` не меняется, права других ролей +(`crm_app_user`, `crm_supplier_worker`) не тронуты. GRANT под гардом на `pg_roles` — на несуществующей +роли `GRANT` роняет миграцию; на `liderra` и `liderra_testing` роль есть, гард нужен для чужого/чистого +окружения. `down()` симметричен — `REVOKE SELECT, UPDATE` под тем же гардом. + +🔴 После выката на прод ПЕРЕзапустить `db/03_service_bypass_policies.sql`: на боевом кластере +`crm_admin_user` **не** BYPASSRLS, табличный GRANT даёт право на таблицу, а кросс-тенантные строки +открывает политика `srv_bypass`. Без перезапуска админ-экран увидит ноль строк — тот же «тихий ноль», +про который предупреждает v9.03. Проверка после выката: +`SELECT policyname FROM pg_policies WHERE tablename='ad_campaign_banners'` — ожидаются две строки, +`tenant_isolation` и `srv_bypass`. + +Миграция `app/database/migrations/2026_07_27_100100_grant_admin_ad_campaign_banners.php`, прогнана на +`liderra_testing`; проверка фактом — `has_table_privilege('crm_admin_user','ad_campaign_banners',…)` +до миграции `false`/`false`, после `true`/`true`. `schema.sql` не тронут — по тому же паттерну, что +v8.99–v9.03. + +⚠️ Точность формулировки выше: прогон на `liderra_testing` подтверждает, что **миграция делает +то, что заявлено** (после неё право появляется), но **не подтверждает состояние боевой базы**. На +`liderra_testing` базовые гранты из `db/02_grants.sql` не раздавались вообще ни одной роли (даже у +`crm_migrator` `has_table_privilege` на `deals` = `false`), поэтому «`false` до миграции» здесь не +отличает «таблица создана позже раздачи прав» от «права на этом окружении никогда не раздавались». +Каково состояние прав на бою — этим прогоном не установлено. + +## v9.03 — 27.07.2026 — номера креативов переезжают с кампании на баннер + +`ad_campaign_banners` + `yandex_creative_id`, `yandex_ad_id`, `moderation_status` (default `draft`), +`moderation_reason`. GRANT SELECT, UPDATE для `crm_supplier_worker` (джоб модерации). + +Причина: конструктор креативов Яндекса закрыт 01.06.2026, адаптивного креатива не существует — +медийная кампания состоит из объявления на каждый размер блока со своим креативом +(findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md). Поле `ad_campaigns.yandex_creative_id` +остаётся как аварийный ручной путь и будет снято отдельно. + +Аддитивно, RLS `tenant_isolation` не меняется. Модель `App\Models\AdCampaignBanner`: четыре константы +модерации (`MOD_DRAFT`/`MOD_MODERATION`/`MOD_ACCEPTED`/`MOD_REJECTED`), новые поля в `$fillable`, +`yandex_creative_id`/`yandex_ad_id` в `casts()` → `integer`. Миграция +`app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php`, прогнана на +`liderra_testing`. Тест `app/tests/Feature/Advertising/BannerYandexIdsTest.php`. `schema.sql` не +тронут — по тому же паттерну, что v8.99–v9.02. + +🔴 После выката на прод ПЕРЕзапустить `db/03_service_bypass_policies.sql`. **Это отменяет обратное +указание записи v8.97**, где для `ad_campaign_banners` перезапуск был признан ненужным: тогда таблица +была чисто клиентской, теперь по ней ходит служебная роль `crm_supplier_worker` (джоб модерации), а на +боевом кластере эта роль **не** BYPASSRLS. Без перезапуска джоб молча увидит ноль строк. +Проверка после выката: `SELECT policyname FROM pg_policies WHERE tablename='ad_campaign_banners'` — +ожидаются две строки, `tenant_isolation` и `srv_bypass`. + +⚠️ Отсутствие привилегий `crm_admin_user` на `ad_campaign_banners` — закрыто в v9.04. + +## v9.02 (2026-07-26) — Реклама «за показы», Часть 4 — адрес сайта клиента и id медийного объявления + +`ad_campaigns` — добавлены две колонки: + +- `landing_url` (`VARCHAR(1024)` nullable) — адрес сайта клиента, куда ведёт баннер по клику; вписывает + **клиент** в мастере кампании, уходит в Директ как `Href` медийного объявления `CpmBannerAdBuilderAd`. +- `yandex_ad_id` (`BIGINT UNSIGNED` nullable) — id единственного медийного объявления в Директе (в модели + показов объявление одно — адаптивный креатив, — поэтому id храним на кампании, для модерации/паузы). + +Аддитивно, RLS `tenant_isolation` не меняется. Отдельный GRANT **не нужен**: `crm_app_user` (клиент, +пишет `landing_url` в мастере) и `crm_supplier_worker` (джоб запуска, пишет `yandex_ad_id`) уже имеют +табличный `INSERT/UPDATE ON ad_campaigns` — так же пишутся соседние `yandex_*` поля. Модель +`App\Models\AdCampaign`: `landing_url` (`string`) и `yandex_ad_id` (`integer`) в `$fillable`+`casts()`. +Миграция `app/database/migrations/2026_07_26_101500_add_landing_url_and_yandex_ad_id_to_ad_campaigns.php`, +прогнана на `liderra_testing`. `schema.sql` не тронут — по тому же паттерну, что v8.99/v9.00/v9.01. + +## v9.01 (2026-07-26) — Реклама «за показы», Часть 4 — номер адаптивного креатива Яндекса на кампании + +`ad_campaigns` — добавлена колонка `yandex_creative_id` (`BIGINT UNSIGNED` nullable) — номер +(CreativeId) адаптивного креатива в Яндекс.Директе; один креатив покрывает все размеры баннера, +поэтому номер хранится на кампании, а не на `ad_campaign_banners`. Пока не заполняется никем — +позже впишет оператор (админ-экран) или робот-креативщик. Аддитивно, RLS `tenant_isolation` +не меняется. GRANT: `crm_app_user` (клиент) и `crm_supplier_worker` (робот-джобы) уже имеют +табличный `UPDATE ON ad_campaigns` (покрывает новую колонку). Для оператора добавлен **точечный** +`GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO crm_admin_user` (least privilege: у admin +на таблице был только SELECT) — с гардом на существование роли (dev/test = postgres superuser). +Модель `App\Models\AdCampaign`: `yandex_creative_id` в `$fillable` и `casts()` → `integer`. +Миграция `app/database/migrations/2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php`, +прогнана на `liderra_testing`. Тест +`app/tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php`. `schema.sql` не тронут — +по тому же паттерну, что и соседние миграции v8.99/v9.00. + +## v9.00 (2026-07-26) — Реклама «за показы»: два режима сбора аудитории + клиентская цена + наша наценка + +`ad_campaigns` — добавлено пять колонок: `mode` (`VARCHAR(10) NOT NULL DEFAULT 'auto'`, `'auto'| +'manual'` — режим сбора аудитории), `snapshot_from`/`snapshot_to` (`DATE` nullable, ручной режим: +период сделок для среза), `run_days` (`SMALLINT` nullable, ручной режим: срок показа в днях), +`client_cpm_rub` (`DECIMAL(8,2)` nullable — клиент-редактируемая цена за 1000 показов конкретной +кампании; `NULL` → берётся дефолт `ad_settings.client_cpm_rub`). Аддитивно, RLS `tenant_isolation` +не меняется; доп. GRANT не нужен — табличный `GRANT SELECT, INSERT, UPDATE ON ad_campaigns TO +crm_app_user` (v8.67, миграция `create_ad_campaigns`) уже покрывает новые колонки. Миграция +`app/database/migrations/2026_07_26_100500_add_mode_price_to_ad_campaigns.php`, прогнана на +`liderra_testing`. + +`ad_settings` — добавлена колонка `ad_margin_percent` (`DECIMAL(5,2) NOT NULL DEFAULT 40.00`) — +наша наценка на модель «за показы» (в Директ уходит меньше, чем платит клиент). Глобальная +однострочная таблица без RLS; доп. GRANT не нужен по тому же паттерну, что и `client_cpm_rub` +(v8.96/2026_07_26_100000) — там явных per-column GRANT'ов не было, колонка покрывается табличным +`GRANT SELECT ON ad_settings TO crm_app_user` / `GRANT SELECT, UPDATE ON ad_settings TO +crm_admin_user`. Отдельно от уже существующего `markup_percent` (30.00, наценка в старой кликовой +модели) — оставлен как есть, не трогаем. Миграция +`app/database/migrations/2026_07_26_100600_add_margin_to_ad_settings.php`, прогнана на +`liderra_testing`. + +Модель `App\Models\AdCampaign`: новые поля в `$fillable`+casts (`snapshot_from`/`snapshot_to` → +`date:Y-m-d`, `run_days` → `integer`, `client_cpm_rub` → `decimal:2`, `mode` → `string`, дефолт +`auto` в `$attributes`), константы `MODE_AUTO`/`MODE_MANUAL`, метод `effectiveCpm()` (своя цена +кампании либо дефолт `ad_settings.client_cpm_rub`). `yandex_cost_rub` остаётся в `$hidden` +(наценка клиенту не видна) — не тронуто. Тест +`app/tests/Feature/Advertising/CampaignModeModelTest.php`. + +## v8.99 (2026-07-26) — Реклама «за показы», Часть 3b-3 — частичное включение баннера в показ + +`ad_campaign_banners` — добавлена колонка `included` (`BOOLEAN NOT NULL DEFAULT true`) — «баннер +идёт в показ». Клиент грузит свой файл на каждый размер отдельно (переезд с автогенерации из +одной картинки); `included` позволяет временно исключить конкретный размер из набора без удаления +строки/файла. RLS `tenant_isolation` не меняется (та же таблица, аддитивная колонка). GRANT `UPDATE +ON ad_campaign_banners TO crm_app_user` — ранее у клиента были только `SELECT, INSERT, DELETE` +(v8.97); переключение/замена баннера требует `UPDATE`. Миграция +`app/database/migrations/2026_07_26_100400_add_included_to_ad_campaign_banners.php`, прогнана на +`liderra_testing`. + +> **Перенумерация (14.07.2026):** записи ниже (v8.67–v8.70) сделаны на ветке `feat/sales-finder` +> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), +> поэтому при сведении они перенумерованы. Содержание не менялось. + +## v8.98 (2026-07-26) — Реклама «за показы», Часть 3b-2 — утверждение набора баннеров + +`ad_campaigns` — добавлена колонка `banners_approved_at` (`TIMESTAMP` nullable) — момент, когда +клиент утвердил сгенерированный набор баннеров. Новая загрузка картинки сбрасывает флаг в NULL +(нужно утверждать заново). RLS `tenant_isolation` и GRANT не меняются (та же таблица, аддитивно). +Миграция `app/database/migrations/2026_07_26_100300_add_banners_approved_at_to_ad_campaigns.php`. + +Endpoint'ы (tenant-scoped, `AdvertisingCampaignController`): `POST campaigns/{id}/banner-source` +(1 картинка → `CampaignBannerService::generate` → 15 баннеров + сброс утверждения), +`GET campaigns/{id}/banners` (список превью), `GET campaigns/{id}/banners/{bannerId}/preview` +(стрим ПРИВАТНОГО файла с диска `local`, не публичный URL), `POST campaigns/{id}/banners/approve` +(ставит `banners_approved_at`; пустой набор → 422). Чужой тенант → 404. + +## v8.97 (2026-07-26) — Реклама «за показы», Часть 3b-1 — набор баннеров кампании + +Новая таблица `ad_campaign_banners` — сгенерированные из ОДНОЙ картинки клиента баннеры +точных размеров блоков Яндекса (медийная). Колонки: `id`, `tenant_id` (FK tenants, cascade), +`campaign_id` (FK ad_campaigns, cascade), `width`/`height` (`SMALLINT` unsigned), `path` (файл на +приватном диске `local` = `storage/app/private`), `bytes` (`INT` unsigned), `timestamps`; индекс +`(tenant_id, campaign_id)`. + +RLS: `ENABLE`+`FORCE ROW LEVEL SECURITY`, политика `tenant_isolation` +(`tenant_id = current_setting('app.current_tenant_id')`) — как у `ad_campaigns`. GRANT +`SELECT, INSERT, DELETE` только `crm_app_user` (клиент). Таблица **клиентская** — служебные роли +(`crm_admin_user`/`crm_supplier_worker`) её не читают, поэтому **`srv_bypass` перезапускать НЕ +нужно** (нет кросс-тенантного доступа служебных ролей). Миграция +`app/database/migrations/2026_07_26_100200_create_ad_campaign_banners.php`, прогнана на +`liderra_testing`. + +Наполняет `App\Services\Advertising\CampaignBannerService::generate()` — прогон исходной картинки +по `BannerSizes` через `BannerGenerator` (Часть 3a), файлы на диск `local`, строки в БД; +перегенерация заменяет прежний набор. `schema.sql` (снимок) — регенерировать при завершении фичи. + +⚠️ **Предупреждение для Части 4 (джоб загрузки баннеров в Яндекс, вердикт rls-reviewer):** Директ- +джобы бегут под `pgsql_supplier` (`crm_supplier_worker`, на кластере НЕ BYPASSRLS — ср. хотфикс v8.94 +для `ad_campaigns`). Когда появится джоб, читающий `ad_campaign_banners` под этой ролью, ему +понадобится `GRANT SELECT ON ad_campaign_banners TO crm_supplier_worker` + перезапуск +`db/03_service_bypass_policies.sql` (srv_bypass), иначе «тихий ноль». Здесь (клиентская генерация) +это не нужно. + +## v8.96 (2026-07-26) — Реклама «за показы», Часть 1 — денежная модель кампании (CPM) + +Перевод рекламного модуля с «за клики» на «за показы» (медийная кампания). Часть 1 из 6 +(spec `docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md`). + +Таблица `ad_settings` — добавлена колонка `client_cpm_rub` (`DECIMAL(8,2)`, `DEFAULT '120.00'`) — +**плоская** клиентская цена за 1000 показов (наценка клиенту не показывается; см. spec §3). Строка +единственная, backfill'ится в той же миграции. RLS нет (глобальная таблица, как было); GRANT +наследуется от таблицы (`SELECT crm_app_user`, `SELECT,UPDATE crm_admin_user`). Миграция +`app/database/migrations/2026_07_26_100000_add_client_cpm_to_ad_settings.php`. + +Таблица `ad_campaigns` — добавлены поля модели «за показы»: `frequency` (`SMALLINT` unsigned, +частота показов на человека), `frequency_period_days` (`SMALLINT` unsigned, период частоты; NULL = +весь срок), `estimated_impressions` / `paid_impressions` (`BIGINT` unsigned), `delivered_impressions` +(`BIGINT` unsigned, `DEFAULT 0`), `budget_rub` (`DECIMAL(14,2)`, клиентская сумма сметы), +`yandex_cost_rub` (`DECIMAL(14,2)`, `DEFAULT '0.00'`, факт расход Яндекса — только для админ-маржи), +`charged_client_rub` (`DECIMAL(14,2)`, `DEFAULT '0.00'`, списано клиенту по факту). Клик-наследие +`weekly_budget_rub` → **nullable** (`DROP NOT NULL`). Клик-поля `click_bid_rub` / `daily_budget_rub` +оставлены (уже nullable) — удаление отдельной уборкой после переезда всего кода. RLS +`tenant_isolation` и GRANT не меняются (та же таблица). Миграция +`app/database/migrations/2026_07_26_100100_add_impression_fields_to_ad_campaigns.php`. + +Обе миграции прогнаны на `liderra_testing` (12/12 рекламных тестов зелёные, вкл. регресс кошелька). +Расчёт денег — новый чистый сервис `App\Services\Advertising\AdImpressionPricing` (bcmath scale 2, +округление клиентской суммы вверх до копейки). `schema.sql` (консолидированный снимок) — +регенерировать при завершении фичи; здесь фиксируются миграции как источник. + +⚠️ **Конфиденциальность маржи (требование для Частей 5/6, вердикт rls-reviewer):** RLS ограничивает +строки, НЕ столбцы. `crm_app_user` имеет table-level `SELECT` на `ad_campaigns`, поэтому клиент +технически может прочитать `yandex_cost_rub` (и вывести наценку) на СВОИХ строках. База это не +запретит — запрет ОБЯЗАН стоять в приложении: клиентские сериализаторы/выдача НИКОГДА не отдают +`yandex_cost_rub` (и margin-производные `charged_client_rub`/факт-расход) в клиентский ответ. Нужен +явный allowlist столбцов для клиентской роли + тест «`yandex_cost_rub` не встречается в клиентском +JSON». `srv_bypass` перезапускать НЕ нужно (таблица уже под RLS, изменения аддитивные). + +## v8.95 (2026-07-25) — Рекламный кошелёк, Часть A — оплата картой ЮKassa зачисляет рекламный кошелёк + +Таблица `saas_transactions` дополнена NOT NULL колонкой `credit_target` +(`VARCHAR(16)`, `DEFAULT 'leads'`) — тот же машинный дискриминатор, что уже +есть у `saas_invoices` (v8.90), только для онлайн-платежей картой. Значения: +`'leads'` (умолчание, старое поведение без изменений) | `'advertising'`. +Миграция +`app/database/migrations/2026_07_25_110000_add_credit_target_to_saas_transactions.php`, +прогнана на `liderra_testing` — DONE. `ADD COLUMN IF NOT EXISTS ... DEFAULT` +squawk-safe (короткий lock, без backfill отдельным UPDATE). + +**Назначение:** закрывает TODO(Б-1) в `PaymentWebhookController` — карта +ЮKassa теперь умеет зачислять и **рекламный кошелёк** (`ad_wallets`), а не +только баланс за лиды (`tenants.balance_rub`), наравне со счёт-фактурой +(`InvoicePaymentService`, v8.90). + +`OnlineTopupService::start()` получил новый последний параметр +`string $creditTarget = 'leads'` (defaulted — существующие вызовы не +затронуты), пишет его в `saas_transactions.credit_target` и ветвит текст +чека/описания. `BillingController::topup()` принимает опциональный +`credit_target` (`sometimes|in:leads,advertising`, умолчание `'leads'`) — +в ветке шлюза (флаг ВКЛ) передаёт его в `OnlineTopupService::start()`; в +ветке-заглушке (флаг ВЫКЛ) `'advertising'` зачисляет `AdWalletService` +напрямую, `'leads'` — прежний код `BillingTopupService` без изменений. + +`PaymentSettlementService::settle()` ветвится по `(string) ($tx->credit_target +?? 'leads')` ПОСЛЕ атомарного claim pending→success (идемпотентность не +тронута): `'leads'` (умолчание) — путь БЕЗ ИЗМЕНЕНИЙ, зачисление через +`BillingTopupService` (ledger `balance_transactions`), `balance_rub_after`/ +`balance_transaction_id` проставляются как раньше; `'advertising'` — +зачисление через `AdWalletService::topup()` (ledger +`ad_wallet_transactions`), `balance_rub_after`/`balance_transaction_id` НЕ +проставляются (эти поля про ledger лидов, у рекламного кошелька свой +ledger). Конструктор инжектит `AdWalletService` третьим аргументом. + +Тест `AdvertisingCardTopupTest` (RED→GREEN, TDD, зеркалит +`AdWalletInvoiceTopupTest` и `PaymentWebhookTest`) — 4 теста (settle +advertising зачисляет кошелёк и не трогает баланс за лиды; settle leads — +регрессия, зачисляет баланс, кошелёк не создаётся; идемпотентность +повторного settle advertising; POST `/api/billing/topup` с +`credit_target=advertising` при флаге ВКЛ создаёт pending-транзакцию +рекламного кошелька). Полный `tests/Feature/Billing` + `tests/Feature/ +Advertising` + `tests/Unit/Advertising` — 250/250, 726 assertions, все +зелёные (регрессий нет). `composer stan` — 0 новых ошибок из затронутых +файлов (только предсуществующие в `SetTenantContext.php` и др., не +относящиеся к этой задаче). + +Структурно: +1 колонка (`credit_target`, NOT NULL DEFAULT) на +`saas_transactions`. Таблиц/индексов/функций/триггеров без изменений. + +## v8.94 (2026-07-25) — GRANT crm_supplier_worker на ad_campaigns/ad_campaign_ads — доступ ночных джобов Директа + +rls-reviewer подтвердил дыру: три ночных джоба Директа (Charge/SyncAudience/ +SyncModeration) перечисляют и обновляют `ad_campaigns`/`ad_campaign_ads` +через соединение `pgsql_supplier` (роль `crm_supplier_worker`, BYPASSRLS). +BYPASSRLS снимает RLS-политику, но НЕ заменяет табличную привилегию — без +GRANT на проде было бы `permission denied for table ad_campaigns/ +ad_campaign_ads` (тот же класс сбоя, что sales_prospects 19.07): + +```sql +GRANT SELECT, UPDATE ON ad_campaigns TO crm_supplier_worker; +GRANT SELECT, UPDATE ON ad_campaign_ads TO crm_supplier_worker; +``` + +SELECT — чтение всеми тремя джобами; UPDATE — запись `moderation_status` +через SyncCampaignModerationJob. Миграция +`app/database/migrations/2026_07_25_100400_grant_supplier_worker_advertising.php` +(`down()` — симметричный REVOKE), гард на существование роли (на dev/тест +роли нет), прогнана на `liderra_testing` — DONE. + +Той же задачей `ChargeCampaignSpendJob` переведён на чтение `ad_settings` +(наценка) по ДЕФОЛТНОМУ соединению вместо `pgsql_supplier` — таблица +глобальная, без RLS, `crm_app_user` уже имеет `SELECT` из миграции +2026_07_24_100300; отдельный supplier-грант не нужен. + +Структурно: 0 новых таблиц/колонок/индексов, +2 GRANT-выражения. +Функций/триггеров без изменений. + +## v8.93 (2026-07-25) — Яндекс-канал, Часть B1, Task 5 — GRANT crm_admin_user на ad_* + модели Eloquent + +Закрывает совет B-adv-1 из RLS-ревью Части A: будущему админ-экрану +расход/маржа нужен read-доступ к рекламным таблицам через +`crm_admin_user` (BYPASSRLS), роль-канон — **`crm_admin_user`** (НЕ +`crm_app_admin`, Р43). Никаких новых таблиц/колонок — только GRANT SELECT +поверх уже существующих: + +```sql +GRANT SELECT ON ad_wallets, ad_wallet_transactions, ad_wallet_holds, ad_settings TO crm_admin_user; +GRANT SELECT ON ad_campaigns, ad_campaign_ads, ad_campaign_phones TO crm_admin_user; +``` + +Миграция `app/database/migrations/2026_07_25_100300_grant_admin_read_advertising.php` +(`down()` — симметричный REVOKE), прогнана на `liderra_testing` — DONE. +`ad_wallets`/`ad_wallet_transactions`/`ad_wallet_holds` (Часть A) и +`ad_campaigns`/`ad_campaign_ads`/`ad_campaign_phones` (Task 3–4 этой части) +получают SELECT; `ad_settings` уже имел `GRANT SELECT, UPDATE TO +crm_admin_user` в своей миграции (2026_07_24_100300) — повторный GRANT +SELECT идемпотентен, не ошибка. + +Той же задачей — три модели Eloquent: `App\Models\AdCampaign` (константы +статусов `STATUS_DRAFT`…`STATUS_STOPPED_NO_FUNDS`, PHP-side default +`status='draft'` через `$attributes` — паттерн `SalesProspect`/`ImportLog`, +т.к. Eloquent не подтягивает DB-default обратно без `refresh()`; relations +`ads()`/`phones()`/`tenant()`), `App\Models\AdCampaignAd` (константы +модерации `MOD_DRAFT|MOD_MODERATION|MOD_ACCEPTED|MOD_REJECTED`, relations +`campaign()`/`tenant()`), `App\Models\AdCampaignPhone` (relations +`campaign()`/`tenant()`). Все — `declare(strict_types=1)`, `casts()` +метод (не `$casts`-свойство), мирроят `AdWallet`/`AdWalletTransaction` +Части A. + +Тест `AdCampaignModelTest` (RED→GREEN, TDD) — создаёт кампанию + одно +объявление, проверяет `status === STATUS_DRAFT`, `ads()->count() === 1`, +`weekly_budget_rub === '2500.00'`. 1/1, 3 assertions, зелёный. Полный +`tests/Feature/Advertising` — 20/20, зелёные (регрессий нет). `composer +stan` — 0 новых ошибок (только предсуществующие `SetTenantContext.php` и +`SmscSmsProviderTest.php:52`). + +Структурно: 0 новых таблиц/колонок/индексов, +2 GRANT-выражения +(множественные таблицы в одном statement). Функций/триггеров без +изменений. +План: `docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md` Task 5. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §4. + +## v8.92 (2026-07-25) — Яндекс-канал, Часть B1, Task 4 — таблицы ad_campaign_ads и ad_campaign_phones (RLS) + +Две новые tenant-scoped дочерние таблицы `ad_campaigns` (объявления кампании +и свой загруженный список телефонов клиента): + +```sql +CREATE TABLE ad_campaign_ads ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE, + campaign_id BIGINT NOT NULL REFERENCES ad_campaigns ON DELETE CASCADE, + title VARCHAR(56) NOT NULL, + title2 VARCHAR(45), -- 30 + 15 узких символов + text VARCHAR(96) NOT NULL, -- 81 + 15 узких символов + href VARCHAR(1024) NOT NULL, + image_normal_hash VARCHAR(255), + image_wide_hash VARCHAR(255), + yandex_ad_id BIGINT, + moderation_status VARCHAR(16) NOT NULL DEFAULT 'draft', -- draft|MODERATION|ACCEPTED|REJECTED + moderation_reason VARCHAR(255), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX ON ad_campaign_ads (tenant_id, campaign_id); + +CREATE TABLE ad_campaign_phones ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE, + campaign_id BIGINT NOT NULL REFERENCES ad_campaigns ON DELETE CASCADE, + phone VARCHAR(11) NOT NULL, -- 79XXXXXXXXX + expires_at TIMESTAMP, -- период, на который клиент закинул номер (Р17) + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX ON ad_campaign_phones (tenant_id, campaign_id); +CREATE UNIQUE INDEX ON ad_campaign_phones (tenant_id, campaign_id, phone); +``` + +Миграции `app/database/migrations/2026_07_25_100100_create_ad_campaign_ads.php` +и `app/database/migrations/2026_07_25_100200_create_ad_campaign_phones.php`, +обе прогнаны на `liderra_testing` — DONE. + +**RLS**: та же идиома, что и у `ad_campaigns`/`ad_wallets` — `ENABLE`+`FORCE +ROW LEVEL SECURITY`, политика `tenant_isolation` `USING (tenant_id = +NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)` на +обеих таблицах. **GRANT**: `ad_campaign_ads` — `crm_app_user` SELECT, +INSERT, UPDATE (объявления не удаляются, только меняют +`moderation_status`); `ad_campaign_phones` — `crm_app_user` SELECT, INSERT, +UPDATE, **DELETE** (клиент вправе удалить свой загруженный номер). GRANT +`crm_admin_user` на обе — отдельной миграцией в Task 5 плана (ещё не +сделано). + +Тест `AdCampaignChildTablesMigrationTest` (RED→GREEN, TDD) — вставка одной +строки объявления и одной строки телефона для тенанта+кампании через +`DB::table(...)`, проверка счётчиков и обратного чтения полей. 1/1, 11 +assertions, зелёный. Полный `tests/Feature/Advertising` — 19/19, зелёные +(регрессий нет). `composer stan` — 0 новых ошибок (только предсуществующие +`SetTenantContext.php` и `SmscSmsProviderTest.php:52`). + +Структурно: +2 таблицы (`ad_campaign_ads`, `ad_campaign_phones`), +2 +RLS-политики (`tenant_isolation`), +2 обычных индекса (`tenant_id, +campaign_id` на каждой) + 1 UNIQUE-индекс (`tenant_id, campaign_id, phone` +на `ad_campaign_phones`). Функций/триггеров без изменений. +План: `docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md` Task 4. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §4. + +## v8.91 (2026-07-25) — Яндекс-канал, Часть B1, Task 3 — таблица ad_campaigns (кампании клиента, RLS) + +Новая tenant-scoped таблица `ad_campaigns` (кампании клиента в Яндекс.Директе, +поверх рекламного кошелька Части A): + +```sql +CREATE TABLE ad_campaigns ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants ON DELETE CASCADE, + channel VARCHAR(16) NOT NULL DEFAULT 'yandex', + name VARCHAR(255) NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'draft', -- draft|pending_moderation|running|paused|rejected|stopped_no_funds + audience_days SMALLINT NOT NULL DEFAULT 10, -- скользящее окно сделок + use_uploaded_list BOOLEAN NOT NULL DEFAULT FALSE, + weekly_budget_rub NUMERIC(14,2) NOT NULL, -- клиентские ₽ + daily_budget_rub NUMERIC(14,2), -- soft-cap (у ручной стратегии Директа поля нет) + click_bid_rub NUMERIC(14,2), + yandex_segment_id BIGINT, -- external_id Аудиторий + yandex_retargeting_list_id BIGINT, + yandex_campaign_id BIGINT, + yandex_ad_group_id BIGINT, + moderation_reason VARCHAR(255), + launched_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX ON ad_campaigns (tenant_id, status); +``` + +Миграция `app/database/migrations/2026_07_25_100000_create_ad_campaigns.php`, +прогнана на `liderra_testing` — DONE. + +**RLS**: та же идиома, что и у `ad_wallets` (Часть A) — `ENABLE`+`FORCE ROW +LEVEL SECURITY`, политика `tenant_isolation` `USING (tenant_id = +NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)`. **GRANT**: +`crm_app_user` — SELECT, INSERT, UPDATE (клиент создаёт/правит свои кампании; +DELETE не даётся — кампании не удаляются, только меняют статус). Роль-канон — +`crm_app_user`/`crm_admin_user` (Р43); GRANT `crm_admin_user` — отдельной +миграцией в Task 5 плана (ещё не сделано). + +Тест `AdCampaignMigrationTest` (RED→GREEN, TDD) — вставка строки кампании +через `DB::table('ad_campaigns')` для тенанта, чтение полей обратно (channel, +status, budgets, yandex_* id). 1/1, 11 assertions, зелёный. + +Структурно: +1 таблица (`ad_campaigns`), +1 RLS-политика (`tenant_isolation`), ++1 индекс (`tenant_id, status`). Функций/триггеров без изменений. +План: `docs/superpowers/plans/2026-07-24-yandex-kanal-chast-B1-backend.md` Task 3. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §4. + +## v8.90 (2026-07-24) — Рекламный кошелёк, Часть A, Task 10 (финал) — оплата по счёту в рекламный кошелёк + +Таблица `saas_invoices` дополнена NOT NULL колонкой `credit_target` (`VARCHAR(16)`, +`DEFAULT 'leads'`) — машинный дискриминатор маршрутизации зачисления, НЕ +путать со свободнотекстовым `payment_purpose` (банковское «назначение +платежа»). Значения: `'leads'` (умолчание, старое поведение без изменений) +| `'advertising'`. Миграция +`app/database/migrations/2026_07_24_100500_add_credit_target_to_saas_invoices.php`, +прогнана на `liderra_testing` — DONE. `ADD COLUMN IF NOT EXISTS ... DEFAULT` +squawk-safe (короткий lock, без backfill отдельным UPDATE). + +**Назначение:** клиент может выставить и оплатить банковским переводом счёт +на пополнение **рекламного кошелька** (`ad_wallets`), а не только баланса +за лиды (`tenants.balance_rub`), как раньше. + +`InvoiceService::create()` получил новый последний параметр +`string $creditTarget = 'leads'` (defaulted — существующие вызовы не +затронуты). `InvoiceController::store()` принимает опциональный +`credit_target` (`sometimes|in:leads,advertising`, умолчание `'leads'`). + +`InvoicePaymentService::markPaid()` ветвится по `$invoice->credit_target`: +`'leads'` (умолчание) — путь БЕЗ ИЗМЕНЕНИЙ, зачисление через +`BillingTopupService` (ledger `balance_transactions`), `balance_rub_after`/ +`balance_transaction_id` на `SaasTransaction` проставляются как раньше; +`'advertising'` — зачисление через `AdWalletService::topup()` (ledger +`ad_wallet_transactions`), `balance_rub_after`/`balance_transaction_id` НЕ +проставляются (эти поля про ledger лидов, у рекламного кошелька свой). Акт +(`ActService::createForInvoice`) создаётся в обеих ветках без изменений +текста/НДС — формулировка акта для рекламных услуг оставлена как есть +(TODO(В7) в коде, ждёт решения бухгалтера). Письмо `InvoicePaidNotification` +после COMMIT — без изменений для обеих веток. + +Вне охвата (задокументировано TODO(Б-1) в `PaymentWebhookController`): +онлайн-оплата картой ЮKassa рекламного кошелька — go-live онлайн-оплаты +ещё не завершён (Б-1), логика webhook не тронута. + +Тест `AdWalletInvoiceTopupTest` (RED→GREEN, TDD, зеркалит +`InvoiceMarkPaidTest`) — 2 новых теста (advertising зачисляет кошелёк и не +трогает баланс за лиды; leads — регрессия, зачисляет баланс, кошелёк не +создаётся). Полный `tests/Feature/Advertising` — 17/17, полный +`tests/Feature/Billing` — 173/173, `tests/Feature/Sales/SalesInvoiceTest` +— 7/7, все зелёные (регрессий нет). `composer stan` — 0 новых ошибок +(только предсуществующие `SetTenantContext.php` и +`SmscSmsProviderTest.php:52`). + +Структурно: +1 колонка (`credit_target`, NOT NULL DEFAULT) на +`saas_invoices`. Таблиц/индексов/функций/триггеров без изменений. +План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 10 (финал). + +## v8.89 (2026-07-24) — Рекламный кошелёк, Часть A, Task 7 — идемпотентный charge (списание по факту расхода) + +Таблица `ad_wallet_transactions` дополнена nullable-колонкой `external_key` +(строка) + UNIQUE-ограничением `(tenant_id, external_key)`. Миграция +`app/database/migrations/2026_07_24_100400_add_external_key_to_ad_wallet_transactions.php`, +прогнана на `liderra_testing` — DONE. + +**Назначение:** идемпотентность списания за фактический расход рекламы +(клики/показы по кампании) по внешнему ключу события (например, +`yandex:1:2026-07-24` — канал:кампания:дата). Postgres допускает +множественные `NULL` в `external_key` под UNIQUE — старые строки +`topup`/`freeze`/`release` (где `external_key IS NULL`) не конфликтуют +между собой. + +Новый метод `AdWalletService::charge()` (MONEY-код, только `bcmath`, +scale 2): замок по кошельку (`lockForUpdate`) ДО проверки идемпотентности +(как в `SmsChargeService`); повторный вызов с тем же `external_key` — +no-op; при недоборе баланса до нуля не уходит в минус (`balance_rub` не +опускается ниже `0.00` — жёсткая остановка по недобору обрабатывается +отдельно, `AdStopAll`, Task 8). Пишет append-only транзакцию +`type=charge` с отрицательной `amount_rub` и `external_key`. + +Тест `AdWalletChargeTest` (RED→GREEN, TDD) + полный набор +`tests/Feature/Advertising` + `tests/Unit/Advertising` — 8/8, 13 +assertions, зелёные. `composer stan` — 0 новых ошибок (только +предсуществующие `SetTenantContext.php` и `SmscSmsProviderTest.php:52`). + +Структурно: +1 колонка (`external_key`, nullable) + 1 UNIQUE-индекс на +`ad_wallet_transactions`. Таблиц/функций/триггеров без изменений. +План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 7. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3. + +## v8.88 (2026-07-24) — Рекламный кошелёк, Часть A, Task 4 — глобальная настройка наценки ad_settings + AdMarkup + +Наценка рекламного кошелька (клиент видит цену/бюджет с наценкой сверху +яндексовой, Яндекс получает бюджет за вычетом наценки) — теперь редактируемый +процент, а не хардкод. Новая таблица `ad_settings`: + +```sql +CREATE TABLE ad_settings ( + id BIGSERIAL PRIMARY KEY, + markup_percent NUMERIC(5,2) NOT NULL DEFAULT '30.00', -- глобальная наценка рекламы + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +``` + +**Намеренно ГЛОБАЛЬНАЯ singleton-таблица** (одна строка на всю систему, +засеяна `markup_percent='30.00'` миграцией) — **без `tenant_id` и без RLS**. +Это не упущение: наценка — общесистемная политика ценообразования, не +tenant-scoped данные, поэтому `tenant_isolation`-идиома здесь неприменима. +**GRANT**: `crm_app_user` — только SELECT (клиентский код читает процент для +расчёта цены); `crm_admin_user` — SELECT + UPDATE (редактирует только админ). +Роль `crm_app_admin`, фигурирующая в некоторых старых условных GRANT-блоках +`schema.sql` (guarded `IF EXISTS ... rolname = 'crm_app_admin'`), в +`00_create_roles.sql` не создаётся — это не активная роль кластера, поэтому +здесь используется реальная админ-роль `crm_admin_user` (BYPASSRLS, см. § +роли в `00_create_roles.sql`). + +Новый money-сервис `App\Services\Advertising\AdMarkup` — конвертация +клиент↔Яндекс через `bcmath` (scale 2, TRUNCATE, не round-half-up): +`factor() = 1 + percent/100`; `clientFromYandex()` = `bcmul(yandex, factor, 2)` +(цена клиенту округляется ВНИЗ — никогда не переплатит по нашей вине); +`yandexFromClient()` = `bcdiv(client, factor, 2)` (бюджет Яндексу округляется +ВНИЗ — никогда не потратим больше клиентских денег). Пример при 30%: Яндекс +12.18 → клиент 15.83 (12.18×1.3=15.834, усечено); клиент 500.00 → Яндекс +384.61 (500/1.3=384.615…, усечено). + +Client-level конфиг, БЕЗ RLS (осознанно, см. выше). Миграция +`app/database/migrations/2026_07_24_100300_add_ad_markup_setting.php`, +прогнана на `liderra_testing` — DONE (строка `markup_percent=30.00` +подтверждена, GRANT `crm_app_user=SELECT` / `crm_admin_user=SELECT,UPDATE` +подтверждены `\dp`). Тест `AdMarkupTest` GREEN (1/1, 2 assertions); полный +набор `tests/Unit/Advertising` + `tests/Feature/Advertising` — 4/4, 7 +assertions. `composer stan` — 0 новых ошибок (единственная — предсуществующая +`SmscSmsProviderTest.php:52`). + +Структурно: +1 таблица (глобальный конфиг, без RLS — намеренно, см. выше). +Индексов/функций/триггеров без изменений. +План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 4. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3. + +## v8.87 (2026-07-24) — Рекламный кошелёк, Часть A, Task 2 — таблицы ad_wallet_transactions и ad_wallet_holds + +Продолжение Task 1 (`ad_wallets`, v8.86): append-only леджер операций кошелька и +активные заморозки под кампании. Две новые таблицы: + +```sql +CREATE TABLE ad_wallet_transactions ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + type VARCHAR(32) NOT NULL, -- topup|charge|freeze|release|refund|manual_adjustment + amount_rub NUMERIC(14,2) NOT NULL, -- + пополнение, − списание + balance_rub_after NUMERIC(14,2) NOT NULL, + channel VARCHAR(32), -- yandex|sms|vk|telegram|ai_call + related_type VARCHAR(255), + related_id BIGINT, + description VARCHAR(255), + created_at TIMESTAMP NOT NULL +); +CREATE INDEX ON ad_wallet_transactions (tenant_id, created_at); +CREATE INDEX ON ad_wallet_transactions (related_type, related_id); + +CREATE TABLE ad_wallet_holds ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel VARCHAR(32) NOT NULL, + source_type VARCHAR(255) NOT NULL, + source_id BIGINT NOT NULL, + amount_rub NUMERIC(14,2) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'active', -- active|released + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + UNIQUE (tenant_id, channel, source_type, source_id) +); +CREATE INDEX ON ad_wallet_holds (tenant_id, status); +``` + +`ad_wallet_transactions` — **append-only леджер** (без `updated_at`, никогда не +UPDATE/DELETE): каждая операция кошелька — новая строка с `balance_rub_after` +(остаток после операции), `amount_rub` со знаком (+/−). `ad_wallet_holds` — +активные заморозки под кампании/каналы, статус `active→released` при снятии +заморозки; уникальность `(tenant_id, channel, source_type, source_id)` +не даёт задвоить заморозку одного источника. + +**RLS** — та же идиома `tenant_isolation` (как в `ad_wallets` v8.86 и +`autopodbor_sources`): `ENABLE` + `FORCE ROW LEVEL SECURITY`, политика по +`tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`. +**GRANT** клиентской роли `crm_app_user`: `ad_wallet_transactions` — +SELECT/INSERT (без UPDATE/DELETE — леджер только дописывается); +`ad_wallet_holds` — SELECT/INSERT/UPDATE (статус переключается). + +Client-level (не supplier), RLS ВКЛЮЧЁН на обеих. Миграции +`app/database/migrations/2026_07_24_100100_create_ad_wallet_transactions.php` + +`2026_07_24_100200_create_ad_wallet_holds.php`, прогнаны на `liderra_testing` — +DONE. Тест `AdWalletTablesMigrationTest` GREEN (1/1, 2 assertions); полный набор +`tests/Feature/Advertising/` — 2/2, 4 assertions. `composer stan` — 0 новых +ошибок (единственная — предсуществующая `SmscSmsProviderTest.php:52`). + +Структурно: +2 таблицы, +2 RLS-политики. Функций/триггеров без изменений. +План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 2. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3. + +## v8.86 (2026-07-24) — Рекламный кошелёк, Часть A, Task 1 — таблица ad_wallets + +Отдельный рекламный кошелёк тенанта (баланс + заморожено), намеренно **не связан** +с `tenants.balance_rub` (баланс лидов) — деньги на рекламу и деньги на лиды не +смешиваются. Новая таблица `ad_wallets`: + +```sql +CREATE TABLE ad_wallets ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE, + balance_rub NUMERIC(14,2) NOT NULL DEFAULT '0.00', + frozen_rub NUMERIC(14,2) NOT NULL DEFAULT '0.00', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); +``` + +Один кошелёк на тенанта (`tenant_id UNIQUE`). `balance_rub` — доступный остаток, +`frozen_rub` — сумма активных заморозок под кампании (Task 3 плана добавит +`ad_wallet_holds`); списание/пополнение/история — `ad_wallet_transactions` (Task 2). + +**RLS** — идиома `tenant_isolation` (как в `autopodbor_sources` v8.5x): `ENABLE` + +`FORCE ROW LEVEL SECURITY`, политика по `tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`. +**GRANT** SELECT/INSERT/UPDATE клиентской роли `crm_app_user` (кошелёк — +клиентские данные тенанта, не поставщика — `crm_supplier_worker` не задействован). + +Client-level (не supplier), RLS ВКЛЮЧЁН. Миграция +`app/database/migrations/2026_07_24_100000_create_ad_wallets.php`, прогнана на +`liderra_testing` — DONE. Тест `AdWalletMigrationTest` GREEN (1/1, 2 assertions). + +Структурно: +1 таблица, +1 RLS-политика. Индексов/функций/триггеров без изменений +(кроме implicit unique-индекса на `tenant_id`). +План: `docs/superpowers/plans/2026-07-24-reklamnyy-koshelek-chast-A.md` Task 1. +Спека: `docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md` §3. ## v8.85 (2026-07-23) — Витрина прогрева, кусок B (B1) — таблица sales_ad_audience_warming_episodes diff --git a/docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-master-pravki.md b/docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-master-pravki.md new file mode 100644 index 00000000..2c3b3368 --- /dev/null +++ b/docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-master-pravki.md @@ -0,0 +1,132 @@ +# HANDOFF — Реклама «за показы», правки мастера по живому фидбеку владельца (Часть 5c) + +## ✅ ЧАСТЬ 5c СДЕЛАНА (26.07.2026, вечер) — НЕ закоммичено, ждёт «го» владельца +Реализовано по плану `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5c-master-pravki.md`: +- **Баннеры — новая модель:** сетка 15 слотов, клиент грузит свой файл на каждый размер; валидация точного + размера картинки + формат (JPG/PNG/GIF) + вес ≤150 КБ; замена, удаление, галочки «в показ» (`included`). + Backend `uploadBanner`/`listBanners`(slots)/`toggleBannerIncluded`/`deleteBanner`/`approveBanners`(по included). +- **Цена настраиваема:** админ `GET/PUT /api/admin/advertising/settings` (`client_cpm_rub`) + поле в + `AdminAdvertisingView.vue`; мастер берёт цену из `audienceSize.cpm_rub` (хардкода «120» в UI больше нет). +- **Правки мастера:** «Утвердить» только после появления баннеров; «Далее» скрыта на последнем шаге; + клиентская проверка размера файла (баннеры и список номеров) с понятным сообщением. +- **Миграция** `..._100400_add_included...` (+GRANT UPDATE), rls-reviewer **PASS**, CHANGELOG v8.99. +- **Проверено:** backend 141/141, фронт 116/116, vite build OK, 0 новых ошибок типов, code-review пройден + (утечки маржи/ПДн/RLS нет). Фиксы после ревью: submit не пускает с нулём включённых баннеров (#1); + расширение файла из содержимого, не из имени клиента (#2); проверка размера файла списка номеров (#4). +- **Ключ AiTunnel убран из `.env`** (ИИ-путь заморожен, см. решение ниже). + +### Остаточные follow-up'ы (НЕ блокеры, по остаточному принципу) +- **#3:** нет уникального индекса `(tenant_id,campaign_id,width,height)` на `ad_campaign_banners` — теоретическая + гонка при параллельной загрузке одного размера (фронт блокирует кнопку на время загрузки, `updateOrCreate` + гасит обычный случай). Добавить индекс отдельной миграцией при желании. +- **Boевые PHP-лимиты** (`post_max_size`/`upload_max_filesize`) под крупные загрузки — на nginx/php-fpm боевого; + сознательно отложено (прод не трогаем без «го»). +- **Часть 4 (Директ, ждёт доступа Яндекса):** GRANT SELECT на `ad_campaign_banners` для `crm_supplier_worker` + + ре-ран `db/03_service_bypass_policies.sql`, когда джоб выката баннеров пойдёт под служебной ролью. + +--- + + +**Дата:** 26.07.2026 (вечер). **Ветка:** `feat/reklama-yandex-pokazy`, HEAD `7d3ed2f6` (после Части 6). +Части 1–3, 5a, 5b, 6 — закоммичены. НА БОЙ НИЧЕГО НЕ ВЫКАЧЕНО. + +## Контекст: что произошло +Владелец (Дмитрий, не программист) прошёл новый мастер показов **живьём** на локальном демо +(`http://localhost:8200`, демо-клиент `omega-demo@liderra.local` / `Liderra2026`, dev-БД `liderra`) и дал +конкретный фидбек по мастеру. Ниже — его правки (дословный смысл) → это **Часть 5c** (доработка мастера). + +## 🔴 Фидбек владельца — правки к мастеру (СДЕЛАТЬ) + +1. **«Человек не поймёт»** — упростить тексты/подсказки на шагах (проще, по-человечески). +2. **Цена 120 ₽/1000 — СДЕЛАТЬ НАСТРАИВАЕМОЙ.** Сейчас в мастере/отчёте зашито «120 ₽ за 1000 показов» + текстом. Значение уже лежит в `ad_settings.client_cpm_rub` (Часть 1), а endpoint + `GET audience-size?...&frequency=` уже возвращает `cpm_rub`. Надо: (а) в UI показывать цену из ответа + сервера (`audienceSize.cpm_rub`), не хардкод; (б) добавить **админ-контрол**, чтобы менять + `ad_settings.client_cpm_rub` (endpoint чтения/записи + поле в админке рекламы). +3. **Экран «Баннер» (шаг 3) непонятен + «Утвердить баннеры» не на месте.** Кнопка «Утвердить баннеры» + НЕ должна показываться на этапе загрузки картинки. Порядок: грузишь картинку → ПОЯВЛЯЮТСЯ готовые + баннеры (галерея) → и ТОЛЬКО внизу под ними кнопка «Утвердить». (Сейчас кнопка висит и до галереи — + `v-if="!bannersApproved"` показывает её disabled даже при пустом списке; надо + `v-if="!bannersApproved && bannerList.length > 0"` и разместить строго после галереи.) +4. **Авто-баннеры «порезаны тупо»** (особенно узкие: 320×50, 728×90, 1000×120, 320×100 — cover-crop + срезает всё, кроме горизонтальной полосы). Нет частичного утверждения / замены. **ДОБАВИТЬ:** + - **Замена отдельного баннера** — на каждый размер можно загрузить свою готовую картинку (вписать + точно в w×h, без порчи). + - **Частичное утверждение** — галочки «какой баннер идёт в показ»; «Утвердить» берёт только выбранные. +5. **Экран «Проверка и отправка» (шаг 4):** убрать кнопку «Далее» на последнем шаге, оставить только + «Отправить заявку». (В футере степпера `wizard-next` сейчас показывается disabled на последнем шаге — + сделать `v-if="step !== stepTitles.length"`.) +6. **Ошибки загрузки** (видел живьём): «**The POST data is too large**» (загрузка списка номеров CSV) и + «**Загрузка поля file не удалась**» (картинка PNG) — упор в PHP `post_max_size` / `upload_max_filesize` + (у него был крупный PNG). Поднять лимиты (для боевого — nginx/php-fpm; локально — `php -d`) + дать + понятное сообщение и client-side проверку размера («картинка слишком большая, до N МБ»). + +## ✅ РЕШЕНО 26.07 (вечер) — авто-кроп/ИИ ОТМЕНЁН. Клиент грузит баннеры сам. +Проверял ИИ-дорисовку вживую на ключе AiTunnel владельца (`gpt-image-1`, `images/edits` + маска): +- маскless-edit **перерисовывал всю картинку заново** (курсор гигантский, золото другое) — владелец: «это лажа»; +- masked-edit + вклейка оригинала в центр = центр честный, НО ИИ подрисовывал лишнее на полях (золотая + загогулина) + видны швы; доводка — платные попытки, не 100% предсказуемо. + +**Вердикт владельца (дословно):** «не паримся, просим все картинки — пусть делает клиент сам! Мы говорим +размер, который нужен, и объём, а дальше сам.» ⇒ **Портал НЕ подгоняет и НЕ рисует.** Показываем список +нужных размеров + предельный вес, клиент грузит готовый баннер под каждый размер. Ключ AiTunnel **убран из +`.env`** (в коммиты не попадал); ИИ-путь заморожен (вернуть — минута, если понадобится). + +### Новая модель шага «Баннеры» (заменяет п.3 и п.4 выше) +- **Слоты по размерам:** экран показывает все 15 форматов `BannerSizes` как слоты; на каждый — своя загрузка. +- **Загрузка на размер:** клиент грузит файл под конкретный `width×height`. Проверяем: изображение JPG/PNG/GIF, + **точное совпадение размера** (иначе понятная ошибка «нужен ровно 728×90, вы загрузили 800×100»), вес ≤ лимит + (наш текущий кап — показываем «до N КБ»; сверку с точными пофрматными лимитами Яндекса — в Части 4). +- **Ничего не режем/не дорисовываем.** Что загрузил клиент, то и хранится (замена = перезалив того же размера, + удаление = убрать слот). +- **Частичное утверждение:** галочки «в показ» (`ad_campaign_banners.included`, default true); «Утвердить» берёт + только загруженные+отмеченные, ≥1. Кнопка — только когда есть хоть один загруженный баннер, строго под сеткой. +- **Backend:** заменить `uploadBannerSource` (single→generate) на `uploadBanner` (per-size, валидация точного + размера); `listBanners` отдаёт required-sizes + состояние (загружен/вес/included) на каждый; + toggle + `included` + delete баннера; `approveBanners` учитывает `included`. `CampaignBannerService::generate`/ + `BannerGenerator` из клиентского потока **уходят** (`BannerSizes` остаётся — это и есть список нужных форматов). +- Миграция `ad_campaign_banners += included BOOLEAN NOT NULL DEFAULT true` → **rls-reviewer** + CHANGELOG. + +## План реализации Части 5c (после ответа по кропу) +**Backend (Laravel/Pest):** +- `ad_settings.client_cpm_rub`: endpoint GET/PUT (админ, saas-admin зона) + UI в админке рекламы. +- Замена баннера: `POST /api/advertising/campaigns/{id}/banners/{bannerId}/replace` — своя картинка → + вписать/обработать точно в `width×height` этого баннера, заменить файл/строку. +- Частичное утверждение: миграция `ad_campaign_banners +included BOOLEAN NOT NULL DEFAULT true` + (RLS-таблица → **rls-reviewer** + запись в `db/CHANGELOG_schema.md`); toggle-endpoint или в approve; + `approveBanners` учитывает только `included`. Launch/показ используют только включённые. +- Кроп по умолчанию — по выбору владельца: contain+fill(blur) / focus-point / как есть (`BannerGenerator`). +- Лимиты загрузки + валидация с понятным сообщением. +**Frontend (Vue, `CampaignWizard.vue` шаг 3/4):** +- «Утвердить» только после галереи; убрать с этапа загрузки. +- Галерея: на каждом баннере «Заменить» (свой файл) + галочка «в показ»; «Утвердить выбранные». +- Цена из `audienceSize.cpm_rub` (не хардкод «120») — в шаге 2, шаге 4, отчёте, списке. +- Шаг 4: убрать `wizard-next` на последнем шаге. +- Упростить тексты шагов; точечное предупреждение на узких форматах. +- Vitest на новые кнопки/флоу; Pest на replace/partial/cpm. + +## Состояние кода / git +- HEAD `7d3ed2f6` (Часть 6). Ниже — что готово: денежное ядро списания `CampaignImpressionCharger`, + клиентский отчёт по показам, мастер показов (5b), скрытие маржи, статусы queued/completed. +- **НЕ закоммичено на момент хендоффа:** правка текста шапки `AdvertisingYandexView.vue` + («платите только за переходы» → «оплата за показы, 120 ₽ за 1000»). Войдёт в коммит этого хендоффа. +- Директ: заявка на ПОЛНЫЙ доступ подана 26.07 (статус «новая»), ждём Яндекс. Картинка через API нельзя + (гибрид). Отложено до Ч.4: джоб показов из отчёта Директа → `CampaignImpressionCharger`, + `campaigns.suspend`, админ-маржа по реальному `yandex_cost_rub`. + +## Демо для владельца (как поднять снова) +- Из `app/`: собрать ассеты `npm run build` (чтобы НЕ зависеть от Vite dev), убрать `public/hot`, затем + `php artisan serve --port=8200` (порт **8200**, т.к. 8000 занят чужим artisan со старым кодом). +- Открывать `http://localhost:8200`, вход `omega-demo@liderra.local` / `Liderra2026` (dev-БД `liderra`, + 2FA выключена, пароль выставлен вручную в dev). У демо-клиента 0 контактов → смета 0; для ненулевой — + на шаге 1 включить «мой список» и вставить номера. +- 🪤 Vite dev на localhost отдаёт ассеты с `http://[::1]:5174` → Chrome блокирует + (`ERR_NETWORK_ACCESS_DENIED`, Private Network Access). Поэтому для демо — только собранные ассеты + (`npm run build`), НЕ `npm run dev`. + +## Грабли окружения / процесс +- `liderra_testing` — общая тестовая БД: `DB_DATABASE=liderra_testing php artisan migrate --force` перед + тестами. Worktree: свой `composer install`; node_modules junction ок; `LEFTHOOK_EXCLUDE=larastan` при + коммите (`Can't find lefthook` — норма). Картинки — только GD. Скилы первыми; subagent-driven + (субагент пишет тест+код, НЕ коммитит; контроллер читает diff сам + rls-reviewer на миграциях + коммит + через «го»). Владелец — простой русский, не программист. На бой/в gitea — только по «го». diff --git a/docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-stroyka.md b/docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-stroyka.md new file mode 100644 index 00000000..d92b9f0f --- /dev/null +++ b/docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-stroyka.md @@ -0,0 +1,48 @@ +# HANDOFF — Реклама «за показы» (переделка clicks→CPM), стройка + +**Дата:** 26.07.2026. **Ветка:** `feat/reklama-yandex-pokazy` (от `main` a487c660, в `.claude/worktrees/reklama-pokazy`). НА БОЙ НЕ ВЫКАЧЕНО, только локальные коммиты. + +## Зачем (одно предложение) +Рекламный модуль портала был построен на модели **«за клики»** (не работает для строгого прогрева сегмента); переводим на **«за показы»** — медийная кампания Яндекса `CpmBannerCampaign`, оплата CPM, клиенту плоская цена **120 ₽ / 1000 показов** (наценка клиенту НЕ видна). + +## Нормативка (источники истины) +- Спека: `docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md` (6 частей, §7 декомпозиция). +- Планы: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast{1,2,3a,3b1}-*.md`. +- Исходный контекст Яндекса: `docs/superpowers/2026-07-25-HANDOFF-reklama-progrev-mediynaya-kampaniya.md` (сегмент 58034825, размеры баннеров §5.2, аккаунты). + +## Сделано и закоммичено (4 части) +| Часть | Что | Коммит | +|---|---|---| +| 1 | Денежное ядро: `AdImpressionPricing` (показы=аудитория×частота, сумма по 120₽/1000 вверх до копейки, маржа); `ad_settings.client_cpm_rub=120`; поля «за показы» в `ad_campaigns` (frequency, *_impressions, budget_rub, yandex_cost_rub, charged_client_rub), weekly_budget_rub→nullable | `c9443dca` | +| 2 | Смета: `CampaignEstimateService` + расширение endpoint `GET /api/advertising/campaigns/{id}/audience-size` (при `frequency` отдаёт impressions/cpm_rub/cost_rub). Аудитория (`deals` за N дней + свой список `ad_campaign_phones`, дедуп, RLS) и порог 100 уже были в `CampaignAudienceBuilder`/`audienceSize` | `f99bc54d` | +| 3a | `BannerSizes` (15 размеров) + `BannerGenerator::coverJpeg` (GD cover-crop в точный размер, JPEG ≤512КБ) | `ec8f189b` | +| 3b-1 | Таблица `ad_campaign_banners` (RLS, GRANT crm_app_user) + модель + `CampaignBannerService::generate` (1 картинка → 15 баннеров на диск `local` + строки; перегенерация заменяет) | `8e91be53` | + +Все части: TDD, diff читал контроллер сам, rls-reviewer PASS на миграциях. Тесты зелёные. + +## Осталось +- **Часть 3b-2 (endpoint'ы баннеров):** `POST` загрузка 1 картинки → `CampaignBannerService::generate`; `GET` список превью (стрим приватного файла, tenant-scoped — НЕ публичный URL); `POST` утверждение набора (флаг на кампании, напр. `banners_approved_at`). + source_image хранение при желании. +- **Часть 4 (связь с Директом) — 🟡 ЖДЁМ ОДОБРЕНИЯ ЯНДЕКСА (заявка на ПОЛНЫЙ доступ подана 26.07):** + - Предыстория: дёргали API Директа реальным OAuth-токеном (приложение «Лидерра — реклама на кандидатов», client_id `5919f23ef9114baf9d14b88cd929a8ae`, права `direct:api`) — err58. Разгадка (26.07): у приложения была заявка на **тестовый** доступ, Яндекс её **отклонил** с причиной «С июля 2026 Песочница не поддерживается, запросите **полный** доступ». Песочницы больше нет ⇒ обкатка Части 4 будет **только на боевом API** (аккуратно, на маленькой сумме). + - ✅ **Подана заявка на ПОЛНЫЙ (боевой) доступ 26.07** через `apiCertificationUpgradeRequest` (НЕ AddRequest — тот даёт «Заявка уже создана Вами»; Upgrade повышает существующую). Кабинет sasha261185 → API → Мои заявки: приложение «Лидерра — реклама на кандидатов», **доступ: полный, статус: новая** (на рассмотрении). «Программный доступ: открыт», IP без ограничений. Заполнено: контакт info@liderra.ru, компания «Лидерра (ИП Кондратьев П. Д.)» / liderra.ru, тип «Прямой заказчик, автоматизирую собственные кампании», PHP+JSON, логин sasha261185, цели automation+sync, функции статистика+управление кампаниями+ставки, описание схемы (CpmBannerCampaign flow), скриншот интерфейса портала, демо-доступ (info@lkomega.ru). Ждём вердикт Яндекса. + - 🧱 **Вторая стена ПОДТВЕРЖДЕНА официальной докой (26.07):** объявление медийной CPM-кампании `CpmBannerAdBuilderAd` требует `Creative.CreativeId`; картинку через `AdImageHash` (как у обычных объявлений) передать **нельзя**. `creatives.add` умеет **только видео** (VideoExtensionCreative по VideoId) — графический баннер-креатив через API создать невозможно. НО: в **веб-кабинете** готовый баннер-креатив создать **можно** (загрузить готовый JPG/PNG/GIF / по шаблону / «Идеи креативов»). ⇒ Часть 4 = **ГИБРИД, выполнимый**: портал генерит баннеры (Часть 3) + автоматизирует сегмент+кампанию+группу+стратегию+частоту+бюджет+объявление через API; **баннер-креатив человек один раз заливает в кабинет Яндекса и вставляет CreativeId в портал**. + - Что делать, когда откроют доступ: переделать `YandexDirectClient` (`TextCampaign`→`CpmBannerCampaign`, `CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, `AverageCpm`/`SpendLimit`) + `CampaignLauncher` (деньги по показам), объявления через CreativeId. Обкатка в песочнице. TDD через `Http::fake` (структуру campaigns.add CpmBannerCampaign сверял — yandex.ru/dev/direct/doc/ru/campaigns/add-cpm-banner-campaign). +- **Часть 5b (мастер, Vue) — ✅ ГОТОВО, коммит `de9f0d7d`:** мастер `CampaignWizard` переделан на 4 шага показов (окно дней → частота+живая смета → 1 картинка+превью+утверждение → проверка+«Отправить заявку»); `store/update` клик→частота/бюджет; новый `submit` → статус `queued` (Директа нет, реальный запуск — Ч.4); `yandex_cost_rub` скрыт от клиента (`$hidden` + тест); `CampaignList` — метка queued + показы. Бэкенд 99/99, фронт 102/102. +- **Часть 6 (отчёты/списание) — ⏳ ЧАСТИЧНО (Direct-независимый слой готов, ждёт коммита):** + - ✅ **Сделано сейчас (без Директа):** `CampaignImpressionCharger` — списание по факту показов (показано×120/1000, дельта, идемпотентно по external_key `yandex-imp:{id}:{billable}`, стоп/`STATUS_COMPLETED` при оплаченном, перекрут режется, bcmath) — 6 boundary-тестов; клиентский отчёт `CampaignReportDialog` под показы (оплачено/показано/частота/потрачено, убран `weekly_budget_rub`) + статусы queued/completed. План: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast6-spisanie-otchet.md`. + - ⏳ **ОТЛОЖЕНО до Ч.4 (нужен Директ):** джоб, тянущий фактические показы из отчёта Директа и зовущий `CampaignImpressionCharger` (переделка `ChargeCampaignSpendJob` клики→показы); `campaigns.suspend` при стопе; **админ-маржа** `AdminAdvertisingController` — сейчас считает расход Яндекса через наценку-% (`client_spend/1.3`, клик-логика); в модели показов маржа = списано − **реальный** `yandex_cost_rub` из отчёта Директа. Пока Директа нет — рабочую админку НЕ трогаем (даст неверную маржу). ⚠️ Взаимодействие charge↔freeze/solvency: `CampaignImpressionCharger` списывает дельту, а заморозка сметы + gate-solvency + stopAll — обёртка джоба (Ч.4). + +## 🔴 Обязательные требования/мины (не потерять) +- **Маржа НЕ видна клиенту** (владелец резко подчёркивал). RLS не режет столбцы: клиентская выдача НИКОГДА не отдаёт `yandex_cost_rub` (и margin-производные) — allowlist столбцов + тест «yandex_cost_rub не в клиентском JSON». Часть 5/6. (CHANGELOG v8.96.) +- **Часть 4 srv_bypass:** джоб загрузки баннеров под `crm_supplier_worker` (`pgsql_supplier`) потребует `GRANT SELECT ON ad_campaign_banners TO crm_supplier_worker` + перезапуск `db/03_service_bypass_policies.sql`, иначе «тихий ноль». (CHANGELOG v8.97.) +- **Модель денег:** клиент платит плоские 120₽/1000 показов по ФАКТУ; заморозка по смете, списание по факту, возврат остатка; тройная страховка от перерасхода (FrequencyCap + фикс.сегмент + джоб-стоп по оплаченным показам). Всё bcmath. + +## Грабли окружения (важно!) +- 🪤 **Тестовая БД `liderra_testing` — ОБЩАЯ, параллельная сессия её периодически обнуляет** (мои колонки/таблицы исчезают). ПЕРЕД прогоном тестов с БД: `DB_DATABASE=liderra_testing php artisan migrate --force` (НЕ `--env=testing` — уходит в дев-БД `liderra`). +- 🪤 Worktree Laravel на Windows: свой `composer install` (vendor нельзя junction'ить — тесты пойдут против чужого кода). Larastan/lefthook в worktree не идут (`Can't find lefthook` при коммите — норма, `LEFTHOOK_EXCLUDE=larastan`). +- 🪤 `origin` = мёртвый GitHub (origin/main СТАРЫЙ). Настоящий main = локальный + gitea. Worktree ветвить от **локального `main`** (`git worktree add ... main`), НЕ от origin. +- Библиотеки картинок: только **GD** (нет Intervention/Imagick). +- Коммит — контроллером через escape (стена), по «го» владельца. Пуш в gitea — НЕ делали (по решению владельца). + +## Процесс стройки +subagent-driven-development: субагент (Sonnet) пишет тест+код, НЕ коммитит; контроллер читает diff сам + перепрогоняет + rls-reviewer на миграциях + коммит через «го» владельца. Спека→план→реализация по частям. diff --git a/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4-v2.md b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4-v2.md new file mode 100644 index 00000000..f9e5ab6f --- /dev/null +++ b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4-v2.md @@ -0,0 +1,106 @@ +# Промт перезапуска v2 — реклама «за показы», Часть 4 + робот-креативщик + +> Скопируй текст ниже целиком в новую сессию (после /compact). + +--- + +Продолжаем рекламу Лидерры «за показы» в Яндексе. Ты в git worktree +`.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. Прод liderra.ru — БОЕВОЙ, +живые деньги: ничего на бой/в gitea без явного «go» владельца; БД по умолчанию только чтение; +наценка/`yandex_cost_rub`/`ad_margin_percent` — НИКОГДА в клиентском JSON; коммиты только по escape, +paren-free, `LEFTHOOK_EXCLUDE=larastan`; субагенты для git — Sonnet, не Haiku, и они НЕ коммитят. +Владелец — не программист, говори простым русским. Рубильник `YANDEX_DIRECT_ENABLED` — ВЫКЛ, +`base_url` — песочница; реальных кампаний в боевом кабинете НЕ создавать до отдельного «go». + +## Большие решения этой сессии (главное) +1. **Песочницы Директа НЕТ** (у аккаунта полный доступ — песочница только под тестовый). Поэтому код + пишем по документации + полностью проверяем `Http::fake`-тестами; живую сверку делаем ОДИН раз при + go-live (Задача 9), под присмотром владельца. +2. **Баннер-креатив через API не залить** (Яндекс принимает картинки только в веб-конструкторе). + Но **один АДАПТИВНЫЙ креатив покрывает все 15 размеров** (не 15 креативов!). Номер креатива + (`CreativeId`) хранится НА КАМПАНИИ (`ad_campaigns.yandex_creative_id`). +3. **Путь:** клиент грузит 1 картинку в портал → оператор/робот делает ОДИН адаптивный креатив в + Яндексе → номер в портал → запуск автоматический. Клиент Яндекс НЕ трогает. Портал может показать + клиенту превью готового креатива (`creatives.get` → `PreviewUrl`/`ThumbnailUrl`/`IsAdaptive`). +4. **Три части проекта (согласовано с владельцем), порядок ЯДРО→РОБОТ→ВЫБОР:** + - **ЯДРО (Часть B) — автозапуск через API** = задачи Части 4. ← делаем сейчас. + - **РОБОТ (Часть C) — робот-креативщик** (Playwright RPA): сам заходит в конструктор Яндекса, грузит + картинку, делает адаптивный креатив, забирает номер; споткнулся (капча/вход/смена вёрстки) → + ПИСЬМО-АЛЯРМ оператору со скриншотом. **Делать ПО ОБРАЗЦУ телеграм-бота:** + `docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md` (та же архитектура: свой + персистентный вход на сервере, keep-alive, watchdog+письмо, режимы черновик/боевой, потолок + трат). Робот сначала получает короткий свой дизайн-спек → одобрение владельца → стройка. + - **ВЫБОР (Часть A) — выбор клиенту в мастере:** «Яндекс сам соберёт все 15 из 1 картинки» + (адаптив) ИЛИ «дам свои 15» (фиксированные креативы, тяжелее); + понятное объяснение, что делает + ИИ Яндекса. Один кривой размер чинится сдвигом кадра в Смарт-центре или заменой этого размера + своей картинкой. + +## Контракт медийного API (сверен по документации, детали — в findings) +Файл: `docs/superpowers/findings/2026-07-26-direct-media-api-contract.md`. Кратко: +- `campaigns.add` `CpmBannerCampaign`: `BiddingStrategy {Search:{BiddingStrategyType:"SERVING_OFF"}, + Network:{BiddingStrategyType:"CP_MAXIMUM_IMPRESSIONS", CpMaximumImpressions:{AverageCpm, SpendLimit, + StartDate, EndDate, AutoContinue}}}`, `FrequencyCap:{Impressions, PeriodDays 1..30}`. Деньги — + МИКРОСЫ (₽ × 1 000 000). +- `adgroups.add` `CpmBannerKeywordsAdGroup: {}` (пустой) + `Name`/`CampaignId`/`RegionIds`. Аудитория — + ОТДЕЛЬНЫМ `audiencetargets.add {AdGroupId, RetargetingListId}` (RetargetingListId из + `RetargetingLists.add`, метод `addRetargetingList` уже есть). +- `ads.add` `CpmBannerAdBuilderAd {Creative:{CreativeId}, Href}` (НЕ image-hash). +- `creatives.get` FieldNames: `Id,Type,PreviewUrl,ThumbnailUrl,IsAdaptive,Width,Height`. +- Отчёт `CAMPAIGN_PERFORMANCE_REPORT`: `Impressions,Cost`. + +## Деньги (важно) +Наценка — `ad_settings.ad_margin_percent` (дефолт 40), модель **«клиент × (1 − наценка/100)»** — ТА ЖЕ, +что в `CampaignImpressionCharger` (Часть 6). НЕ `AdMarkup`-делением `÷(1+30%)`. `AverageCpm` в Директ = +`effectiveCpm() × (1 − margin/100) × 1e6`. `SpendLimit` = `estimated_impressions/1000 × яндекс_cpm × +services.yandex_direct.spend_limit_guard_multiplier (1.2)`, микросы. Всё bcmath, микросы — целые. + +## СДЕЛАНО этой сессией (⚠️ НЕ ЗАКОММИЧЕНО, лежит в worktree) +- **Задача 2 (config):** `config/services.php` → `yandex_direct.spend_limit_guard_multiplier` (1.2) + + тест `tests/Unit/Advertising/YandexDirectConfigTest.php`. ✓ +- **Миграция:** `2026_07_26_101000_add_yandex_creative_id_to_ad_campaigns.php` — nullable bigint + `ad_campaigns.yandex_creative_id` + точечный `GRANT UPDATE (yandex_creative_id) ON ad_campaigns TO + crm_admin_user` (гард на роль). Модель `AdCampaign`: поле в `$fillable`+`casts()=integer`. Тест + `tests/Feature/Advertising/AdCampaignCreativeIdMigrationTest.php`. Запись `db/CHANGELOG_schema.md` + v9.01 (в WORKTREE — не в основной папке!). rls-reviewer пройден. ✓ +- Тесты: `tests/Feature/Advertising` 140/140, `tests/Unit/Advertising` 22/22 зелёные. +- План обновлён: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md`. +- 🔴 **Коммита ещё НЕ было** — владелец не давал «go». Первый чистый кусок (config+миграция+доки) + готов к коммиту; спросить владельца: коммитить кусками или одним коммитом в конце. + +## ОСТАЛОСЬ по ядру (делать через subagent-driven-development, TDD, субагенты НЕ коммитят) +- **Задачи 3–6:** медийные методы в `app/app/Services/Advertising/YandexDirectClient.php` + + `Http::fake`-тесты (`tests/Unit/Advertising/YandexDirectMediaClientTest.php`): + `addCpmBannerCampaign(...)`, `addCpmBannerAdGroup(...)`, `addMediaAudienceTarget(adGroupId, + retargetingListId)` (без ContextBid — стратегия CPM), `addCpmBannerAd(adGroupId, creativeId, href)`, + `getCreativePreview(creativeId)`. Старые клик-методы пока НЕ удалять. +- **Задача 7:** переписать `CampaignLauncher::launch()` под показы: цепочка `addRetargetingList → + addCpmBannerCampaign → addCpmBannerAdGroup → addMediaAudienceTarget → addCpmBannerAd(campaign + .yandex_creative_id)`; наценка «минус 40%»; заморозка в клиентских ₽; ОДНО объявление; если + `yandex_creative_id` пуст → понятная ошибка (не 500); money-leak тест (нет + `yandex_cost_rub`/`ad_margin_percent` в JSON). Строить из `ad_campaign_banners` только проверку + «есть включённые+утверждённые». +- **Задача 8:** контроллер `submit`/`launch` + чистка легаси (`weekly_budget_rub`, `click_bid_rub`, + `AdMarkup`, старые `addCampaign(TextCampaign)`/`addTextAd`/`addAudienceTarget(ContextBid)` если не + нужны) + **админ-поле ввода номера креатива** (`AdminAdvertisingController` + + `AdminAdvertisingView.vue`, роль crm_admin_user) + `getCampaignSpend` под показы (Impressions). +- **Задача 9 (при go-live, отдельный «go»):** один контролируемый пробный запуск в боевом кабинете + под присмотром, немедленная остановка, сверить реальные поля с findings. Рубильник НЕ включать сам. +- **Задача 10:** финальное ревью (`superpowers:requesting-code-review`), `composer test`, хэндофф + + чек-лист go-live. +Потом — **РОБОТ (Часть C)** и **ВЫБОР (Часть A)**. + +## Как работаю / грабли окружения +- subagent-driven-development: субагент пишет тест+код, НЕ коммитит; я ревьюю diff, гоняю тесты, + коммичу по escape владельца. RLS-правки → прогонять агента `rls-reviewer`. +- Worktree: свой `composer install` (vendor есть). **Larastan в worktree молча падает exit-1** + (junction-vendor, известная граблина) → `--error-format=json` или опираться на Pest. Тесты — на + `liderra_testing`. Ключевые файлы: `YandexDirectClient.php`, `CampaignLauncher.php`, + `AdvertisingCampaignController.php`, `AdminAdvertisingController.php`, `AdCampaign.php` (уже с + `yandex_creative_id`), `AdCampaignBanner.php` (15 размеров, флаг `included`), `BannerSizes.php` + (15 размеров), `CampaignImpressionCharger.php` (модель наценки), `config/services.php`. +- 🔴 CHANGELOG схемы правь в **worktree** `db/CHANGELOG_schema.md`, НЕ в основной папке репо (в этой + сессии субагент ошибся — правил основную; починено). +- Память: `project-reklama-modul-vykat-2026-07-25` (доступ Директа + токен + грабля srv_bypass). + +Сначала прочитай план и findings, затем продолжай по задачам ядра. Отчитывайся владельцу простым +языком; на необратимое/коммит — спрашивай «go». diff --git a/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4.md b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4.md new file mode 100644 index 00000000..2cb74472 --- /dev/null +++ b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-chast4.md @@ -0,0 +1,68 @@ +# Промт перезапуска — реклама «за показы», Часть 4 (связь с Директом) + +> Скопируй текст ниже целиком в новую сессию. + +--- + +Продолжаем рекламный модуль Лидерры «за показы» (Яндекс). Ты в worktree +`.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy` (HEAD `5a4c0e02`). +Прод liderra.ru — БОЕВОЙ, живые деньги: ничего на бой/в gitea без явного «go» владельца; +БД по умолчанию только чтение; наценка/`yandex_cost_rub`/`ad_margin_percent` — НИКОГДА клиенту; +коммиты только по escape, paren-free, `LEFTHOOK_EXCLUDE=larastan`; субагенты для git — Sonnet, НЕ Haiku, +и они НЕ коммитят. Владелец — не программист, говори простым русским. + +## Что уже сделано (26.07.2026) +- **Доступ к API Директа ОДОБРЕН** (кабинет `sasha261185` → Настройки API → Мои заявки: + «одобрена», доступ «полный», «Программный доступ: открыт», 32000 баллов). ClientID приложения + `5919f23ef9114baf9d14b88cd929a8ae`. +- **OAuth-токен создан и установлен на бой:** прод `app/.env` → `YANDEX_DIRECT_TOKEN` (bearer, ~363 дня). + Живой `campaigns.get` к `api.direct.yandex.com` → 200 OK (вернул 2 ручные кампании владельца). + 🔴 Рубильник `YANDEX_DIRECT_ENABLED` = **ВЫКЛ**, `base_url` = **песочница** — портал сам в Директ + НЕ ходит. Токен разово светился в переписке прошлой сессии — при паранойе перевыпустить. +- **Почти весь модуль показов уже готов** (Части 1–3, 5, 6): модель `AdCampaign` на показы + (`frequency`, `estimated_impressions`, `paid_impressions`, `delivered_impressions`, + `client_cpm_rub`, `yandex_cost_rub`, `mode` auto|manual, `effectiveCpm()`), смета, баннеры, + мастер, списание по факту (`CampaignImpressionCharger` / `ChargeCampaignSpendJob`). +- **Не сделана только Часть 4** — реальный запуск в Директ. Сейчас `CampaignLauncher`/`YandexDirectClient` + строят ТЕКСТОВУЮ кампанию «за клики»; `submit()` просто ставит статус `queued` (в коде комментарий + «реальный запуск доделает Часть 4»). Медийных методов в `YandexDirectClient` нет. + +## Твоя задача: выполнить Часть 4 по готовому плану +План (полный, с задачами и тестами): +`docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md` +Дизайн (источник истины, лежит в ОСНОВНОЙ папке репо, не в worktree): +`docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md` + +Суть: переписать запуск с текстовой кампании (клики) на **медийную `CpmBannerCampaign` (за показы)**: +сегмент Аудиторий → ретаргетинг-условие → медийная кампания (`CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, +`SpendLimit`=бэкстоп) → группа `CpmBannerKeywordsAdGroup` (автотаргетинг OFF, без ключей, единственное +условие = наш сегмент) → медийные объявления `CpmBannerAd` → заморозка клиентских денег. В Директ уходит +`client_cpm × (1 − ad_margin_percent/100)` (дефолт наценки 40%). Всё за рубильником. + +Исполнять через **`superpowers:subagent-driven-development`** (TDD, субагенты НЕ коммитят; ты ревьюишь diff, +гоняешь тесты, коммит по «go»). Начни с **Задачи 1 — спайк в песочнице**: живой вызов сверяет ТОЧНЫЕ поля +медийного API (документация — лишь гипотеза). + +🔴 **Блокер:** песочница Директа для `sasha261185` не инициализирована — `campaigns.get` к +`api-sandbox.direct.yandex.com` даёт **err 513 «логин не подключён»**. Задача 1 Шаг 1 — включить песочницу +(браузер `sandbox.direct.yandex.ru` под тем же логином ИЛИ первый create). У владельца есть залогиненный +кабинет Яндекса в Playwright-браузере, доступ к кабинету он дал. **Боевой рубильник НЕ включать**, реальных +кампаний в боевом кабинете НЕ создавать — обкатка только в песочнице; выкат на бой — отдельным «go». + +## Доступы / инфраструктура +- Прод SSH: `ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137`, приложение `/var/www/liderra/app`. + `.env` читается только под www-data → команды через `sudo -u www-data bash -c "cd /var/www/liderra/app && ..."`. + `config:cache` — ТОЛЬКО под www-data (квирк 107). tinker — код через stdin, не `--execute` (квирк 110). + SSH рвётся на длинных/параллельных командах — одна команда на вызов (квирк 109). +- Проверка API на бою (read-only, безопасно): скрипт `campaigns.get` в tinker (см. прошлую сессию). +- Windows worktree: свой `composer install`; larastan `--error-format=json` (память `feedback-worktree-laravel-windows`). +- Память: `project-reklama-modul-vykat-2026-07-25.md` (доступ Директа + токен + грабля srv_bypass на RLS-таблицах). + +## Ключевые файлы Части 4 +- `app/app/Services/Advertising/YandexDirectClient.php` (+медийные методы, убрать клик-методы) +- `app/app/Services/Advertising/CampaignLauncher.php` (переписать `launch()` под показы) +- `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` (`submit`/`launch`) +- `app/app/Models/AdCampaign.php` (убрать легаси `weekly_budget_rub`/`click_bid_rub`) +- `app/config/services.php` (параметры медийной стратегии; рубильник НЕ включать) + +Сначала прочитай план и дизайн, затем выполняй по задачам. По ходу — отчитывайся владельцу простым языком. diff --git a/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-master-pravki.md b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-master-pravki.md new file mode 100644 index 00000000..5e20c94e --- /dev/null +++ b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy-master-pravki.md @@ -0,0 +1,61 @@ +# Промт для перезапуска — Реклама «за показы», правки мастера (Часть 5c) + +Продолжаем рекламный модуль портала Лидерры «за показы» (CPM). 🔴 Боевой прод lk.liderra.ru — живые +клиенты и деньги. На бой НИЧЕГО не выкачено, всё в ветке. Владелец — Дмитрий, не программист, простой +русский, «не уверен — спроси». + +## СНАЧАЛА ПРОЧИТАЙ +1. 📌 ГЛАВНОЕ — фидбек-хендофф: `docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-master-pravki.md` + (правки мастера по живому тесту владельца + ОТКРЫТЫЙ вопрос по кропу/ИИ + план Части 5c + как поднять демо). +2. Общий хендофф стройки: `docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-stroyka.md`. +3. Спека: `docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md`. + Планы: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast{5b,6}-*.md`. +4. CLAUDE.md; память `feedback-worktree-laravel-windows`, `feedback_environment`, + `project-reklama-modul-vykat-2026-07-25`. + +## ГДЕ РАБОТАТЬ +Worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`, HEAD после этого хендоффа. +Laravel в `app/`. Бэкенд-тесты: `DB_DATABASE=liderra_testing php artisan migrate --force` затем +`php artisan test tests/Feature/Advertising`. Фронт: `npx vitest run tests/Frontend/advertising-*.spec.ts`. + +## ГОТОВО (закоммичено) +Части 1–3 (деньги/смета/баннеры), 5a (фронт-API), 5b (мастер показов, коммит `de9f0d7d`), 6 (списание по +факту `CampaignImpressionCharger` + отчёт по показам, коммит `7d3ed2f6`). Маржа скрыта от клиента. Бэкенд +рекламы 105/105, фронт 104/104. + +## 🔴 ПЕРВЫЙ ШАГ: спросить владельца про кроп/ИИ +Владелец на живом тесте сказал про баннеры: «а если мы ИИ отдавать будем? пусть рисует!?» и НЕ выбрал +вариант. Спроси, какой из трёх (детали и мой расклад — в фидбек-хендофф §«ОТКРЫТЫЙ ВОПРОС»): +(1) сейчас умная подгонка «вписать целиком+фон» бесплатно + замена + частичное утверждение, ИИ потом +[моя рекомендация]; (2) сразу ИИ-outpainting (платный сервис, выбрать); (3) ставка на нейро-креативы +Яндекса при выкате. От ответа зависит авто-кроп (пункт 4). Замену и частичное утверждение — делать в любом +случае. + +## ЗАТЕМ — Часть 5c (правки мастера по фидбеку, всё в фидбек-хендофф) +1. Упростить тексты шагов (человеку непонятно). +2. Цена 120₽/1000 — НАСТРАИВАЕМАЯ: в UI брать `audienceSize.cpm_rub` (не хардкод), + админ-контрол на + `ad_settings.client_cpm_rub`. +3. Шаг «Баннер»: «Утвердить» только ПОСЛЕ галереи (не на этапе загрузки). +4. Баннеры «порезаны»: замена отдельного баннера своей картинкой + частичное утверждение (галочки «в + показ»); авто-кроп по выбору владельца. Миграция `ad_campaign_banners +included` → rls-reviewer. +5. Шаг «Проверка»: убрать «Далее», оставить только «Отправить заявку». +6. Ошибки загрузки (POST too large / файл не загрузился) — поднять php-лимиты + понятное сообщение + + client-side проверка размера. + +## ОТЛОЖЕНО до Части 4 (нужен Директ, заявка на доступ «новая», ждём Яндекс) +Джоб показов из отчёта Директа → `CampaignImpressionCharger`; `campaigns.suspend` при стопе; админ-маржа +по реальному `yandex_cost_rub`. Картинка-креатив через API нельзя (гибрид — баннер в кабинет вручную). + +## ДЕМО (владелец тыкает) +`http://localhost:8200`, вход `omega-demo@liderra.local` / `Liderra2026` (dev-БД `liderra`). Поднять: +из `app/` → `npm run build` (не `npm run dev` — Vite-ассеты с `[::1]:5174` Chrome блокирует) → убрать +`public/hot` → `php artisan serve --port=8200` (8000 занят чужим artisan). У демо-клиента 0 контактов → +смета 0; для ненулевой — на шаге 1 включить «мой список» + вставить номера. + +## ПРОЦЕСС +Скилы первыми (Frontend Design для UI, subagent-driven для реализации). Субагент (Sonnet, НЕ Haiku для git) +пишет тест+код и НЕ коммитит; контроллер читает diff САМ (не верить «тесты зелёные»), перепрогоняет, +rls-reviewer на миграциях, коммит через «го» владельца (`LEFTHOOK_EXCLUDE=larastan`, явные пути). НЕ пушить +в gitea/на бой без «го». + +ПЕРВЫЙ ШАГ: прочитай фидбек-хендофф, спроси владельца про кроп/ИИ (1/2/3), начинай Часть 5c с Frontend Design. diff --git a/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy.md b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy.md new file mode 100644 index 00000000..57e9fa6c --- /dev/null +++ b/docs/superpowers/2026-07-26-PROMPT-restart-reklama-pokazy.md @@ -0,0 +1,51 @@ +# Промт для перезапуска сессии — Реклама «за показы» + +Продолжаем переделку рекламного модуля портала Лидерры с модели «за клики» на «за показы» +(медийная кампания Яндекса, CPM). 🔴 Боевой прод lk.liderra.ru — живые клиенты и деньги. +На бой НИЧЕГО не выкачено, всё в ветке. + +## СНАЧАЛА ПРОЧИТАЙ (восстанови контекст) +1. 📌 ГЛАВНОЕ — HANDOFF стройки (в ветке): `docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-stroyka.md` — полный статус: что готово, что заблокировано, грабли, требования. +2. Спека: `docs/superpowers/specs/2026-07-25-yandex-reklama-medijnaya-pokazy-design.md` (6 частей, §7 декомпозиция). ⚠️ Спека и планы лежат в ОСНОВНОМ checkout `c:\моя\проекты\портал crm\Документация\docs\superpowers\{specs,plans}\` (писались до worktree, возможно НЕ закоммичены — проверь, при нужде скопируй в ветку). +3. Планы частей: `docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast{1,2,3a,3b1,3b2,5a}-*.md`. +4. CLAUDE.md (§0/§1/§3/§5), память: `feedback-worktree-laravel-windows`, `feedback_environment`, `project-reklama-modul-vykat-2026-07-25`. + +## ГДЕ РАБОТАТЬ +- Worktree: `c:\моя\проекты\портал crm\Документация\.claude\worktrees\reklama-pokazy` (в него зайти через EnterWorktree `path`). +- Ветка `feat/reklama-yandex-pokazy` от локального `main` (a487c660). HEAD = `1faf6ea7`, 7 коммитов. НЕ запушено в gitea, НЕ в main, НЕ на бой. +- Laravel в подпапке `app/`. Тесты: `php artisan test ...` (Pest, БД liderra_testing) и `npx vitest run tests/Frontend/` (node_modules подключён junction'ом; если нет — `New-Item -ItemType Junction` из основного checkout). + +## ЧТО ГОТОВО (закоммичено, TDD, diff читан контроллером, миграции — rls-reviewer PASS) +- **Ч.1** `c9443dca` — денежное ядро: `AdImpressionPricing` (показы=аудитория×частота, сумма 120₽/1000 вверх до копейки, маржа), `ad_settings.client_cpm_rub=120`, поля «за показы» в `ad_campaigns`, weekly_budget_rub→nullable. +- **Ч.2** `f99bc54d` — смета: `CampaignEstimateService` + endpoint `GET audience-size?days=&frequency=` отдаёт impressions/cpm_rub/cost_rub. Аудитория (`deals` за N дней + свой список) и порог 100 уже были. +- **Ч.3a** `ec8f189b` — `BannerSizes` (15 размеров) + `BannerGenerator` (GD cover-crop в точный размер, ≤512КБ). +- **Ч.3b-1** `8e91be53` — таблица `ad_campaign_banners` (RLS) + `CampaignBannerService::generate` (1 картинка → 15 баннеров на диск `local`). +- **Ч.3b-2** `2c8c876d` — endpoint'ы: banner-source (загрузка), banners (список превью), banners/{id}/preview (стрим приватного файла), banners/approve; `ad_campaigns.banners_approved_at`. Tenant-scoped, чужой→404. +- **Ч.5a** `1faf6ea7` — фронт `api/advertising.ts`: fetchAudienceSize(id,days,frequency), uploadBannerSource/fetchBanners/approveBanners/bannerPreviewUrl (+типы). + +## 🔴 ЗАБЛОКИРОВАНО ЯНДЕКСОМ — Ч.4 (связь с Директом) +Проверено ЖИВЬЁМ 26.07 (OAuth-токен приложения «Лидерра — реклама», client_id `5919f23ef9114baf9d14b88cd929a8ae`, права direct:api): и боевой, и песочница API Директа → **err58 «Незавершённая регистрация: заявка на доступ к API для приложения не одобрена»**. Аккаунт: «Программный доступ открыт», но «Мои заявки» ПУСТ; висит требование верификации через **Госуслуги**. ⇒ **никакой Direct API сейчас не работает**, Ч.4 ждёт одобрения Яндекса (внешний барьер). +- 🧱 Вторая стена: медийное объявление требует `CreativeId`, а баннер-креатив через API создать НЕЛЬЗЯ (`creatives.add` — только видео). ⇒ Ч.4 будет ГИБРИД: портал автоматизирует сегмент+кампанию+группу+стратегию+частоту+бюджет; **баннер-креатив человек заливает руками в кабинете и вводит CreativeId в портал**. +- Действия ВЛАДЕЛЬЦА (только он): пройти Госуслуги-верификацию + подать/дождаться заявку на API. Спроси статус в начале сессии. + +## ЧТО ДАЛЬШЕ (по приоритету) +1. **Ч.5b — мастер `CampaignWizard.vue` (~690 строк, клик→показы)** — САМЫЙ БОЛЬШОЙ клиентский кусок. Шаги: окно дней (своё число) → частота + живая смета (fetchAudienceSize с frequency) → загрузка 1 картинки + превью-галерея (fetchBanners/bannerPreviewUrl) + утверждение (approveBanners) → «Запустить». Пока Директ закрыт — шаг запуска как «заявка отправлена / ждёт оператора». Vue 3 + **Vuetify 3 + палитра Forest** (НЕ новая айдентика — система портала). Убрать «цену за клик». Component-Vitest. Навык **Frontend Design** обязателен (§12). + - 🔴 ГЛАВНОЕ требование владельца: **клиент НЕ видит маржу/цену Яндекса** — только 120₽/1000 и сумму. Клиентская выдача НИКОГДА не отдаёт `yandex_cost_rub` (allowlist столбцов + тест «yandex_cost_rub не в клиентском JSON»). + - Переделать `store/update` контроллера кампаний: клик-поля → frequency + budget из сметы. +2. **Ч.6 — отчёты/списание** (живой частью зависит от Ч.4): отчёт по показам (не кликам), списание с кошелька по факту (`AdWalletService::charge`, идемпотентно), стоп при достижении оплаченного, админ-маржа (`AdminAdvertisingView`, маржа = списано − yandex_cost). Скрыть yandex_cost от клиента. +3. Когда Яндекс откроет доступ — Ч.4: `YandexDirectClient` TextCampaign→CpmBannerCampaign (`CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, `AverageCpm`/`SpendLimit`) + `CampaignLauncher` (деньги по показам), объявления через введённый CreativeId. Обкатка в песочнице, TDD через `Http::fake`. srv_bypass: джоб под crm_supplier_worker потребует `GRANT SELECT ON ad_campaign_banners TO crm_supplier_worker` + перезапуск `db/03_service_bypass_policies.sql`. + +## ГРАБЛИ ОКРУЖЕНИЯ +- 🪤 **liderra_testing — ОБЩАЯ БД, параллельная сессия её обнуляет.** Перед тестами с БД: `DB_DATABASE=liderra_testing php artisan migrate --force` (НЕ `--env=testing` — уходит в дев-БД). +- 🪤 Worktree Laravel на Windows: свой `composer install` (vendor нельзя junction). node_modules junction — можно. Larastan/lefthook в worktree нет (`Can't find lefthook` при коммите — норма; `LEFTHOOK_EXCLUDE=larastan`). +- 🪤 `origin` = мёртвый GitHub (origin/main СТАРЫЙ). Настоящий main = локальный + gitea. Ветвить от локального `main`. +- Картинки: только GD (нет Intervention/Imagick). +- 🪤 Браузер MCP залипает → `taskkill //F //IM chrome.exe` + `rm SingletonLock` (профиль mcp-chrome-517c7e1). + +## ПРОЦЕСС И ПРАВИЛА +- Скилы ПЕРВЫМИ (Pravila §12): brainstorming (если новое), Frontend Design (UI), subagent-driven-development (реализация), superpowers (процесс). Планы — writing-plans. +- subagent-driven: субагент (Sonnet, НЕ Haiku для git) пишет тест+код и НЕ коммитит; контроллер читает diff САМ (не верить «тесты зелёные»), перепрогоняет, rls-reviewer на миграциях, коммитит через «го» владельца (`LEFTHOOK_EXCLUDE=larastan`, явные пути). НЕ пушить в gitea/на бой без явного «го». +- Владелец — **Дмитрий, не программист**: говорить простым русским, без жаргона. «Не уверен — спроси». Упёрся в стену — остановись и скажи (как с Ч.4). +- Клиентский тест-вход: `info@lkomega.ru` / `Qwerty9363` на lk.liderra.ru. Директ-кабинет: `sasha261185`. + +ПЕРВЫЙ ШАГ: прочитай HANDOFF, зайди в worktree, спроси у владельца статус Госуслуг/заявки на API Директа, и начинай с Части 5b (мастер) — навык Frontend Design первым. diff --git a/docs/superpowers/2026-07-27-HANDOFF-reklama-pokazy-chast4-yadro-STATE.md b/docs/superpowers/2026-07-27-HANDOFF-reklama-pokazy-chast4-yadro-STATE.md new file mode 100644 index 00000000..0513ff76 --- /dev/null +++ b/docs/superpowers/2026-07-27-HANDOFF-reklama-pokazy-chast4-yadro-STATE.md @@ -0,0 +1,80 @@ +# HANDOFF — реклама «за показы», Часть 4 ЯДРО готово (27.07.2026) + +> Состояние: **ЯДРО (автозапуск через API Директа) собрано и закоммичено в ветку +> `feat/reklama-yandex-pokazy`. НЕ влито в main, НЕ на проде, рубильник ВЫКЛ.** +> Осталось: живая сверка при go-live (Задача 9, отдельный «go»), затем РОБОТ (Часть C) +> и ВЫБОР (Часть A). + +## Что сделано (коммиты ветки, снизу вверх) + +| Коммит | Что | +|---|---| +| `5a4c0e02` | Модель показов Части 1–6 (баннеры/режимы/цена/наценка) — прошлые сессии | +| `fbbe49da` | Фундамент: config `spend_limit_guard_multiplier`; колонка `yandex_creative_id` + точечный GRANT админ-роли; CHANGELOG v9.01 | +| `12ef186a` | Медийные методы `YandexDirectClient` (`addCpmBannerCampaign/AdGroup/MediaAudienceTarget/Ad`, `getCreativePreview`) + 7 тестов | +| `83c8eec6` | `CampaignLauncher` под показы: проверки → аудитория ≥100 → деньги bcmath 40% → цепочка запуска → заморозка → `pending_moderation`; колонки `landing_url`+`yandex_ad_id`; CHANGELOG v9.02 | +| `3b55c96a` | Суточное списание по ПОКАЗАМ (`getCampaignImpressions` ALL_TIME → `CampaignImpressionCharger`), а не по кликам | +| `f3d1d23b` | Админ-отчёт маржи на `ad_margin_percent` 40% вычитанием; удалён `AdMarkup`/30% | +| `27ab44ba` | Удалён мёртвый клик-код клиента (`addCampaign/addTextAd/addAudienceTarget/addAdGroup`) + поля `weekly_budget_rub`/`click_bid_rub` | +| `00a89992` | Поле «адрес сайта» (`landing_url`) в мастере кампании + валидация store/update | +| `3917fb13` | Админ-экран ввода номера креатива оператором (`campaignsAwaiting`/`setCampaignCreative`) | +| `03db14ab` | Фиксы по ревью: `paid_impressions` при запуске (потолок сметы), монотонный `charged_client_rub`, уникальный `ReportName`, `effectiveCpm` на bcmath | + +**Единая денежная модель везде:** клиент × (1 − `ad_margin_percent`/100) → в Директ. Одна формула +в запускателе, суточном списании и админ-отчёте маржи. Всё bcmath, микросы целые. Наценка и +`yandex_cost_rub` клиенту не видны (`$hidden`). + +**Тесты:** рекламный модуль **196/196** зелёный (на чистой БД). + +## Чек-лист go-live (Задача 9 — ТОЛЬКО по отдельному «go» владельца, на боевом кабинете) + +1. Переключить `.env`: `YANDEX_DIRECT_BASE_URL=https://api.direct.yandex.com`, + `YANDEX_DIRECT_ENABLED=true` (временно), затем `php artisan config:cache` **под www-data** + (квирк #107 — иначе портал ляжет). +2. Один контролируемый пробный запуск ОДНОЙ кампании с крошечным сегментом и минимальным + `SpendLimit`; проверить 200 OK на каждом методе, тип кампании `CPM_BANNER_CAMPAIGN`, id проставлены. +3. **Немедленно** остановить (`suspendCampaign`) до начала открутки показов. +4. Сверить реальные имена enum/полей с `docs/superpowers/findings/2026-07-26-direct-media-api-contract.md` + раздел «⚠️ Проверить при go-live»; поправить код при расхождении. +5. **Проверить отчёт показов** (`getCampaignImpressions`): формат TSV vs async/поллинг; что + уникальный `ReportName` даёт СВЕЖИЕ данные (не кэш); что тело — одно число показов. +6. Вернуть `base_url`/рубильник в безопасное состояние до решения о полном go-live. + +## 🔴 Открытые вопросы / риски (решить ДО реального списания на бою) + +- ~~**Заморозка не снимается при завершении/остановке.**~~ ✅ **ЗАКРЫТО 27.07.2026, коммит `dd894a52`.** + Разбор показал, что дыра была ШИРЕ и БЛОКИРУЮЩЕЙ: `charge()` уменьшал `balance_rub`, но не трогал + `frozen_rub` — одни и те же рубли считались дважды, свободный остаток (`balance − frozen`) уходил в + минус, а `AdWalletGate::isSolvent()` проверяется СРАЗУ после списания в `ChargeCampaignSpendJob` → + клиент объявлялся неплатёжеспособным после ПЕРВОГО суточного списания и `AdStopAll` глушил все его + кампании. Кампания умерла бы после первого дня показов даже при полном кошельке. + Исправлено: заморозка тает вместе со списанием + снимается на всех **4 выходах** — + `completed` (`CampaignImpressionCharger`), `rejected` (`SyncCampaignModerationJob`), + `stopped_no_funds` (`PauseCampaignsOnAdStop`), `paused` (контроллер); `resume` морозит + неоткрученный остаток сметы ДО обращения к Директу и отдаёт 409 при нехватке денег. + Решение владельца по паузе: на паузе деньги свободны, при возобновлении морозим снова. + Удаление кампании выходом НЕ является — `destroy` разрешён только для черновика. + Тесты рекламного модуля 156/156. Промт продолжения — `2026-07-27-PROMPT-restart-reklama-pokazy-v5.md`. +- **Отчёт показов Директа** — контракт (TSV/async/кэш по ReportName) подтверждён только по документации, + живьём проверяется в Задаче 9. +- **Один адаптивный креатив** оформляется вручную в веб-конструкторе Яндекса; номер вписывает оператор + в админ-экране «Реклама → Кампании на запуск». Автоматизация оформления — Часть C (РОБОТ). + +## Грабли окружения (для следующей сессии) + +- `AdvertisingCampaignEndpointTest` идёт БЕЗ изоляции (нет `RefreshDatabase`/`DatabaseTransactions`) — + оставляет строки в `liderra_testing` навсегда → при накопительных прогонах ложно краснеет + `AdminAdvertisingSpendTest` (читает все `ad_wallet_transactions` глобально). Лечится + `DB_DATABASE=liderra_testing php artisan migrate:fresh --force` перед прогоном. Правильный фикс — + изолировать этот тест (отдельная маленькая уборка). +- **Larastan в этом worktree не запускается** (junction-vendor рушит bootstrap, `composer stan` exit 1, + пустой вывод при `--error-format=json`). Гонять `composer stan` на обычном чекауте / в CI перед merge. +- Коммиты: `LEFTHOOK_EXCLUDE=larastan`, сообщение paren-free, по эскейпу. + +## Что дальше (порядок согласован: ЯДРО → РОБОТ → ВЫБОР) + +- **РОБОТ (Часть C):** робот-креативщик (Playwright RPA по образцу телеграм-бота + `docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md`) — сначала дизайн-спек → одобрение + владельца → стройка. +- **ВЫБОР (Часть A):** выбор клиенту в мастере (Яндекс сам 15 размеров из 1 картинки / свои 15) + + объяснение ИИ Яндекса. diff --git a/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md b/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md new file mode 100644 index 00000000..69984288 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md @@ -0,0 +1,1531 @@ +# Ход починки по приёмочному листу v12 + +**Спутник файла** `2026-07-27-PROMPT-restart-robot-kreativov-v12.md` — там сам приёмочный лист +(разделы 6, 7, 8). Здесь только отметки «сделано / не сделано» и то, чего в листе нет. + +Обновлено: 27.07.2026, работа идёт. + +Р1–Р5 закоммичены — `d26716ed` и `b8f75b2a`. Р6, Р7, Р8 — +**не закоммичено**. + +## Зелёная отметка после Р1–Р5 (коммит `b8f75b2a`) + +Прогнано в одиночку, ничего параллельно. Свежая отметка после Р6–Р8 — ниже, в конце файла. + +- портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + → **266/266, 936 проверок, ~44 с** (было 247/247 до починки — добавилось 19 новых тестов); +- робот: `cd bots/yandex-creatives && node --test` → **35/35**. + +Тестовая база `liderra_testing_reklama` уже промигрирована двумя новыми миграциями. + +## Сделано + +### Р1 — деньги клиента замерзали навсегда ✅ + +Кампания, не добравшая смету показов, висела `running` вечно, заморозка `ACTIVE` навсегда. + +- новая колонка `ad_campaigns.shows_until` (date, nullable) — миграция + `app/database/migrations/2026_07_27_120000_add_shows_until_to_ad_campaigns.php`; +- `CampaignLauncher` пишет её **вместе с `yandex_campaign_id`** (строка 149) — момент, когда + дату начинает держать Яндекс; `resolvePeriod()` переиспользует уже записанную дату, чтобы + возобновлённый запуск не разошёлся с EndDate в Директе; +- `AdCampaign` — `shows_until` в `$fillable` и в `casts()` (`date:Y-m-d`); +- `CampaignImpressionCharger` — у **выхода №1** появилось второе условие (`$periodOver`). + 🔴 Новых вызовов `release()` НЕ добавилось: их по-прежнему четыре, проверено; +- запись **v9.07** в `db/CHANGELOG_schema.md`; +- `rls-reviewer` → **GO** (доп. гранты не нужны, `03_service_bypass_policies.sql` из-за этой + миграции перезапускать не надо — новых таблиц нет; squawk на `up()` чист). + +Тесты: 3 новых в `CampaignImpressionChargerTest.php` (истёк срок → completed + заморозка снята; +срок открыт → running; последний день → ещё running) + 1 в `CampaignLauncherTest.php` +(дата записана и совпала с EndDate, ушедшим в Директ). +**Проверено вырезанием:** убрал `|| $periodOver` → красный; сменил `>` на `>=` → красный на +«последнем дне»; убрал запись `shows_until` в лаунчере → красный. + +### Р2 — отчёт робота принимался по любому заданию в любом состоянии ✅ + +`{ok:true}` по чужому `queued`-заданию разложил бы номера креативов чужой кампании по её +баннерам; `{ok:false}` по закрытому заданию переписал бы правильный результат на `failed`. + +- `CreativeRobotController::done()` — после `findOrFail` проверка `status === taken`, иначе + **409** с человеческим текстом (404 для несуществующего задания сохранён); +- `CreativeJobService::complete()` и `fail()` — та же проверка своим `assertTaken()` + (`RuntimeException`), потому что сервис зовут не только из контроллера. + +Тесты: 4 новых негативных в `CreativeRobotEndpointTest.php` (по `queued` → 409; повторное +«готово» → 409 и номер креатива цел; «сбой» поверх закрытого → 409; отчёт по уже `failed` → +409 и первая причина цела). Все четыре были красными до правки. + +### Р3 — гонка выдачи заданий ✅ + +Сделано: + +- миграция `app/database/migrations/2026_07_27_120100_add_single_taken_guard_to_ad_creative_jobs.php` + — частичный уникальный индекс `uq_creative_job_single_taken ON ad_creative_jobs ((status)) + WHERE status = 'taken'`: гарантия «в работе не больше одного» на уровне базы; +- `CreativeJobService::takeNext()` — `pg_advisory_xact_lock(hashtext('ad_creative_jobs_take'))` + **первой строкой транзакции**, чтобы штатный путь не упирался в нарушение индекса, а + спокойно отвечал роботу «работы нет». + +Тест: `CreativeJobServiceTest.php` — «база не даёт второму заданию уйти в работу» (прямая +запись мимо сервиса → `QueryException`). Был красным до миграции. + +Вторая половина — доделана после коммита `d26716ed`: + +- адрес файла теперь `GET /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file` — маршрут + (`app/routes/web.php`) и контроллер (`file(int $jobId, int $bannerId)` + `file_url` в ответе + `next()`). **Робота править не пришлось**: он берёт адрес из ответа портала как есть + (`src/portal.js:28`), сам его не собирает. В `README.md` и `.env.example` адрес не упомянут — + там только `PORTAL_BASE_URL`. Поправлен фиктивный адрес в `test/portal.test.js`; +- враньё в комментарии `CreativeRobotController.php:63-64` убрано: докстрока теперь говорит, + что «в работе не больше одного» держит индекс, а выдача файла ни на что постороннее не + опирается; +- `rls-reviewer` по миграции 120100 → **GO**. Отдельно разобран RLS-side-channel (нарушение + уникальности видно поверх RLS): живого пути нет — в `taken` пишет только служебный канал под + `crm_admin_user`, у которой и так `srv_bypass USING (true)`, а `crm_app_user` создаёт задания + только в `queued`. squawk по `up()` — 0 issues; `CONCURRENTLY` не нужен и физически не может + быть добавлен без `$withinTransaction = false`; +- запись **v9.08** в `db/CHANGELOG_schema.md`. + +Тесты: 2 новых в `CreativeRobotEndpointTest.php`. **Проверено вырезанием:** убрал +`->where('id', $jobId)` → красный. + +🪤 Первая версия теста «отдаёт файл» была **ложно зелёной**: на незнакомый адрес отвечает +страница сайта, тоже 200. Голого `assertOk()` мало — проверять содержимое. + +### Р4 — ручки баннеров не заперты после заведения кампании в Яндексе ✅ + +Замок стоял только на правке параметров кампании; перезаливка, включение/выключение и удаление +баннера были открыты. Последствия: в портале новая картинка, а в Яндексе крутится старая +(номера креатива и объявления оставались от прошлой); удаление строки с `yandex_ad_id` +заставляло возобновляемый запуск завести **второе** объявление того же размера, а старое +продолжало крутиться за деньги клиента. + +- новый `AdvertisingCampaignController::bannersLocked()` — 409 по тому же признаку + `yandex_campaign_id !== null`, что и замок на `update()`; подключён к `uploadBanner()`, + `toggleBannerIncluded()`, `deleteBanner()`; +- отдельная, более жёсткая защита: баннер с непустым `yandex_ad_id` не удаляется **никогда**, + даже если номер кампании пуст. + +Тесты: 4 новых в `CampaignBannerEndpointsTest.php`. **Проверено вырезанием:** обе защиты +вырезаны по очереди — краснеют все четыре. + +### Р5 — у «запустить» не было защиты от повторного нажатия ✅ + +Два параллельных POST проходили проверку статуса оба и шли по всей цепочке: два сегмента, два +ретаргетинга, **две CPM-кампании**, две группы, два комплекта объявлений. Заморозка денег одна +(она идемпотентна) — портал списал бы за одну кампанию, а крутились бы две. + +- новый статус `AdCampaign::STATUS_LAUNCHING` (колонка `status` — `string(24)` без CHECK, + миграция не нужна); +- `CampaignLauncher::claim()` — под `lockForUpdate()` в транзакции атомарно переводит кампанию + в `launching`; второй заход получает прежний отказ 409 и **в Яндекс не ходит**; +- `launch()` обёрнут в `try/catch(Throwable)`: на любой ошибке возвращается прежний статус, а + номера созданных в Яндексе сущностей уцелевают — возобновляемый запуск не тронут; +- брошенный захват старше **15 минут** (`STUCK_LAUNCH_MINUTES`) перехватывается, иначе убитый + процесс запер бы кампанию в `launching` навсегда. Признак свежести — `updated_at`: живой + запуск сам обновляет строку, записывая номера сущностей; +- фронт: `launching` добавлен в тип `CampaignStatus` и в обе карты `STATUS_META` + (`CampaignList.vue`, `CampaignReportDialog.vue`) — `Record` требует все + ключи, иначе `vue-tsc` упал бы. + +Тесты: 4 новых в `CampaignLauncherTest.php`. **Проверено вырезанием:** убрал запись захвата → +красный; убрал возврат прежнего статуса → красных восемь. + +🪤 Первый тест на захват был **пустышкой**: он сам ставил `launching` в базу, поэтому мутант +«не писать захват» выживал. Настоящую проверку дало подглядывание изнутри запуска — заглушка +Яндекса читает статус кампании в базе в момент, когда запуск уже идёт. + +### Р6 — очередь робота встаёт колом навсегда ✅ + +Три части, все три сделаны. + +1. **Робот, обрезка причины.** `portal.js` — `reportFailure()` режет причину до 900 знаков + с многоточием в конце. Портал принимает не длиннее 1024, а сообщения Playwright при + таймауте штатно тянут «Call log:» на десятки строк — то есть отчёт о САМОМ частом виде + сбоя портал отвергал бы целиком. +2. **Робот, видимый провал отчёта.** `runner.js` — ошибка `reportFailure` больше не глотается + молча: её текст едет в письмо-алярм вместе с предупреждением «задание могло остаться + в работе». +3. **Портал, `done()` ловит `Throwable`.** Приём отчёта ходит в живой Яндекс; любая ошибка + API раньше улетала наружу (робот получал 500), и задание навсегда оставалось `taken`. + Теперь — запись в журнал + `failed` с причиной + 200 роботу. +4. **Реаниматор.** Новая команда `creative-jobs:reap` + (`app/app/Console/Commands/ReapStuckCreativeJobs.php`): `taken` старше **30 минут** + возвращается в `queued`, после **3** попыток закрывается `failed`. В расписании каждые + 10 минут (`app/routes/console.php`). 🔴 Ходит через `pgsql_admin` — на дефолтной роли + без tenant-контекста RLS отдал бы ноль строк, и сторож рапортовал бы об успехе, ничего + не разбирая. + +Тесты: 2 новых у робота (`portal.test.js`, `runner.test.js`), 1 в `CreativeRobotEndpointTest.php`, +новый файл `CreativeJobReaperTest.php` (4 теста) + 1 в `AdvertisingScheduleTest.php`. +**Проверено вырезанием:** обрезка причины, захват ошибки отчёта, порог времени сторожа, +лимит попыток сторожа, пометка задания сбойным в `done()` — краснеет каждая. + +### Р7 — робот докладывал «сбой» об успешной работе ✅ + +Обрыв на докладе «готово» (502 от nginx, таймаут) уходил в общий обработчик сбоя, и робот +слал порталу «сбой» — на СВОЮ УДАЧНУЮ работу. Задание умирало, креативы оставались в кабинете. +Хуже: если портал успел принять «готово», а ответ потерялся, «сбой» затирал правильный +результат с уже проставленными номерами. + +- `runner.js` перестроен: `try` заканчивается на `uploadCreatives`, доклад вынесен ЗА него — + под обработчик сбоя он больше не попадает физически; +- доклад повторяется **3 раза** (`reportDoneWithRetries`), пауза между попытками — + `REPORT_RETRY_DELAY_MS`, по умолчанию 3000 мс (новый ключ в `config.js` и `.env.example`); +- не прошло и после повторов — **только письмо человеку**, `reportFailure` не зовётся никогда; +- `cabinet.js` — уход со страницы после «Создать» обёрнут `.catch(() => {})`: работа уже + сделана, сетевая икота на уборке за собой не должна выдаваться за «креативы не загрузились». + +Тесты: 2 в `runner.test.js`, 1 в `cabinet.test.js`, 1 в `config.test.js`. +**Проверено вырезанием:** число повторов, глушение осечки ухода и сам вынос доклада +(возврат старого поведения «докладываем сбой») — краснеет каждое. + +### Р8 — бесконечная заливка мусора в живой кабинет ✅ + +Пустой список ожидаемых размеров `match()` молча считал успехом: цикл не выполнялся, +возвращалась пустота. Пустым он становится сам собой — если после выката не перезапустить +`db/03_service_bypass_policies.sql`, служебная роль не увидит ни одного баннера. Дальше +задание помечалось «готово» с нулём номеров, запуск снова видел баннеры без креативов и +ставил новое задание — робот заливал те же файлы по кругу, оставляя каждый раз пачку +мусорных креативов в живом кабинете. В журнале всё зелёное. + +- `CreativeIdMatcher::match()` — бросает `CreativeMatchFailedException` при пустом списке + ожидаемых размеров, текст прямо называет вероятную причину; +- `CreativeJobService::complete()` — та же проверка по баннерам **до** похода в Яндекс, + задание закрывается сбойным (вынесен общий `markFailed()`). + +Тесты: 2 в `CreativeJobServiceTest.php`, второй проверяет `Http::assertNothingSent()` — +в Яндекс не ходили вовсе. **Проверено вырезанием:** обе защиты, краснеет каждая. + +## Зелёная отметка после Р1–Р8 + +- портал: **274/274, 957 проверок, ~45 с**; +- робот: **41/41**; +- вызовов `AdWalletService->release(` в коде по-прежнему **четыре** — пятого не появилось + (остальные `->release()` в репозитории — снятие замков `Cache::lock`, не деньги). + +## Хвосты портала П1–П8 — закрыты ✅ (не закоммичено) + +### П1 — обрыв Яндекса давал клиенту голый 500 ✅ + +Постановка задания стояла ДО `try` и ходила в живой Яндекс за слепком. + +- в `AdvertisingCampaignController::launch()` постановка обёрнута `try/catch(Throwable)`: + запись в журнал + **503** с человеческим текстом «кабинет не отвечает, картинки и настройки + сохранены, попробуйте позже»; +- 🔎 **вторая половина П1 не подтвердилась:** «без таймаута» — неверно. У HTTP-клиента Laravel + таймауты по умолчанию есть (`timeout` 30 с, `connect_timeout` 10 с, проверено в + `vendor/.../PendingRequest.php`). Бесконечного зависания не было, поэтому ничего не меняли. + +После П3 постановка вообще перестала ходить в Яндекс, поэтому тест переписан: постановка +роняется через подмену в контейнере — проверяется, что **любая** поломка даёт 503, а не 500. + +### П2 + П6 — роботу отдавались все баннеры, включая уже залитые ✅ + +- новый `CreativeJobService::bannersToUpload()` — включённые баннеры **без** `yandex_creative_id`; + один и тот же список и выдаётся роботу (`CreativeRobotController::next()`), и сопоставляется + при отчёте (`complete()`). Раньше стороны расходились: баннер с уже проставленным номером + попадал в список ожидаемых размеров, нового креатива его размера в разнице слепков не было — + и опознание падало на безупречной работе робота, а следующее задание плодило дубли; +- **постраничный обход** `listImageCreativeIds()`: Яндекс отдаёт опись страницами и признаком + `LimitedBy`. Слепок обрывался на первой странице (потолок 10 000). + +Тесты: 2 в `CreativeJobServiceTest.php`, 1 в `YandexDirectMediaClientTest.php`. +**Проверено вырезанием:** убрал `whereNull('yandex_creative_id')` → красное. + +### П3 — слепок снимался при постановке, а не при выдаче ✅ + +Два задания в очереди получали одинаковый слепок, второе падало **всегда**. + +- `enqueue()` в Яндекс больше не ходит вовсе (и постановка не зависит от живости кабинета); +- слепок снимается в `takeNext()` **в той же транзакции**, что и перевод в `taken`: не снялся — + откатился и захват, задание осталось в очереди, робот придёт снова. + +🪤 Это сетевой вызов внутри открытой транзакции — осознанно, обосновано в комментарии. + +Тесты: 3 в `CreativeJobServiceTest.php`. 🪤 Тест «слепок снимается при выдаче» сам по себе был +**пустышкой** (он одинаково зелен при обоих вариантах) — усилен проверкой «пока задание в +очереди, слепка НЕТ». + +### П4 — номер креатива брался на веру ✅ + +- новый `YandexDirectClient::getCreativeSizes(array $ids)` — настоящие размеры **одним** + запросом на весь набор; +- `CampaignLauncher::assertCreativeSizesMatch()` — сверка перед созданием объявлений, только + по баннерам без номера объявления. Не сошлось или креатива нет в кабинете — запуск не идёт. + +Тесты: 2 в `CampaignLauncherTest.php`. **Проверено вырезанием** → красное. + +### П5 — не было уникального индекса на слот баннера ✅ + +- миграция `2026_07_27_130000_add_unique_slot_to_ad_campaign_banners.php` — + `uq_ad_campaign_banner_slot (tenant_id, campaign_id, width, height)`; запись **v9.09**; +- `rls-reviewer` → **GO** (перезапуск `03_service_bypass_policies.sql` не нужен, грантов + не требуется, side-channel закрыт формой ключа: `tenant_id` первой колонкой; squawk чист). + +🪤 В тестовой базе накопились дубли от прежних прогонов — миграция упала. Дубли почищены +**только в тестовой базе**; миграция оставлена строгой, чтобы на бою не удалять картинки молча +(на боевом таблицы ещё нет вовсе). + +⚠️ Известный остаток (замечание ревьюера, не чинили): проигравший гонку теперь получит 500 от +нарушения уникальности, а его файл останется лежать на диске сиротой. Честного теста на это +без настоящей гонки не написать; размен осознанный — 500 лучше двух объявлений за деньги. + +### П7 — замок на правку не покрывал окно между сегментом и кампанией ✅ + +Замок расширен: `yandex_campaign_id !== null || yandex_segment_id !== null`. + +**Смежная находка, в листе её не было:** перезаливка картинки не сбрасывала +`yandex_creative_id`. Робот уже отвёз креатив, клиент передумал и залил другую картинку — +запуск создал бы объявление по СТАРОЙ. Теперь номер обнуляется вместе с картинкой. + +Тесты: 2 в `AdvertisingCampaignEndpointTest.php`, 1 в `CampaignBannerEndpointsTest.php`. + +### П8 + Р-х5 — файл всегда отдавался как `.jpg` ✅ + +- портал отдаёт настоящее расширение и настоящий `Content-Type`, имя — `{номер баннера}-{Ш}x{В}.{ext}`; +- робот берёт расширение из `Content-Type` (`portal.js`), имя строит от `banner_id` + (`runner.js`). Контракт `downloadBanner` изменён: принимает путь **без** расширения, + возвращает фактический. + +Тесты: 1 в `CreativeRobotEndpointTest.php`, 2 в `test/portal.test.js`. + +## Зелёная отметка после хвостов портала + +- портал: **287/287, 995 проверок, ~46 с**; +- робот: **43/43**; +- вызовов снятия заморозки — по-прежнему **четыре** (`PauseCampaignsOnAdStop:48`, + `SyncCampaignModerationJob:130`, `AdvertisingCampaignController:383`, + `CampaignImpressionCharger:96`); прочие `->release()` — замки `Cache::lock`. + +## Хвосты денег Д1–Д6 — закрыты ✅ (кроме Д4 — вопрос владельца) + +### Д1 — на кампанию не записывалась цена, по которой заморожены деньги ✅ + +Кампания могла завестись без своей цены; тогда `effectiveCpm()` читал глобальную из настроек. +Админ менял глобальную цену — и суточное списание шло по НОВОЙ, выше замороженной. Теперь +`client_cpm_rub` пишется в финальном `update()` лаунчера. + +### Д2 — суточное списание читало кампанию без замка строки ✅ + +`ChargeCampaignSpendJob` — `lockForUpdate()`. Идемпотентность держится на ключе +`yandex-imp:{кампания}:{показы}`, а число показов приходит из отчёта Директа: два +одновременных прогона получили бы РАЗНЫЕ ключи, и уникальный индекс дубль не остановил бы. + +Тест смотрит на сам запрос через `DB::listen` (настоящую гонку в тесте не поставить). + +### Д3 — ошибка Директа глоталась, а деньги размораживались всё равно ✅ + +Пауза, не дошедшая до Директа, — не пауза: реклама крутится, портал показывает «на паузе», +деньги свободны. Теперь `callDirect()` возвращает текст ошибки, а `pause()` при неудаче +отдаёт **409** и заморозку не снимает. + +🔴 У `resume()` поведение НЕ менялось намеренно: там заморозка ставится ДО обращения к +Директу, и отказ потребовал бы её снять — то есть **пятое** место разморозки. Их ровно +четыре. Обосновано в докстроке `callDirect()`. + +**Смежная находка:** там же стояло `!== true` — рубильник, заданный в `.env` строкой «1», +читался бы как выключённый: в Директ не пошли бы, а пауза сочла бы это успехом и +разморозила деньги. Приведено к `! config(...)`. + +### Д4 — НЕ ТРОГАЛИ, вопрос владельца + +Как писать проводку при нехватке денег: на фактически списанное или отдельной строкой +«недобор». Влияет на отчёт по марже. Лист v12 §13 п.7. + +### Д5 — рубильник обходился служебным каналом робота ✅ + +`CreativeJobService::client()` строился безусловно: выдача задания и приём отчёта ходили +в живой кабинет мимо рубильника. Добавлена та же проверка, что в лаунчере. + +### Д6 — `=== false` вместо `! config(...)` ✅ + +`YANDEX_DIRECT_ENABLED=0` в `.env` даёт строку «0»: рубильник считает её выключённым, +а строгое сравнение — включённым. **Проверено вырезанием** (первый красный был из-за +опечатки в самом тесте, поэтому проверили отдельно) → красное. + +## Хвосты робота Р-х1–Р-х6 — закрыты ✅ + +### Р-х1 — `.env` читался от каталога запуска ✅ + +Новый `src/env.js` (`loadEnvFile()`) считает путь от корня робота; все три запускалки +переведены на него. + +### Р-х2 — два процесса дрались за профиль браузера ✅ + +Новый `src/lock.js`: файл-замок `robot.lock` в корне робота, атомарный через флаг `wx`. +Занят — процесс уходит **молча с кодом 0** (задание остаётся в очереди), а не падает +внутри рабочего блока с пометкой задания сбойным. Брошенный замок перехватывается через +30 минут; нечитаемый — сразу. Подключён к `bin/run.js` и `bin/keepalive.js`, добавлен +в `.gitignore` и описан в README. + +Тесты: новый `test/lock.test.js` (6 штук). + +### Р-х3 — письмо врало «ничего не менял», когда файлы уже залиты ✅ + +У `alarm()` появился признак `uploaded`. После заливки письмо прямо говорит «креативы +в кабинет уже загружены» и зовёт человека посмотреть кабинет и очередь. + +🔑 Побочно вскрылось: `mailer.js` тянул `nodemailer` верхним импортом, а зависимости +локально не ставились — **тексты писем не проверялись ни одним тестом вообще**. Настоящий +транспорт вынесен в `src/smtp.js` (как в своё время `human.js`), появился `test/mailer.test.js`. + +### Р-х4 — тест-пустышка про рабочую папку ✅ + +Написан настоящий: запуск **из чужого каталога** и **без** явной рабочей папки. +**Проверено вырезанием:** подмена умолчания на `process.cwd()` → красное (прежний тест +оставался зелёным). + +### Р-х5 — расширение файла и имя по номеру баннера ✅ + +Сделано вместе с П8 (см. выше). + +### Р-х6 — `Number(env.X ?? '800')` ✅ + +Новый помощник `number(env, key, fallback)`: пустое значение — это «не задавал, возьми +обычное», а мусор — внятная ошибка вместо тихого `NaN`. Прежде пустой `HUMAN_DELAY_MS` +давал 0 (робот щёлкал с машинной скоростью — так антифрод и опознаёт бота), а нечисловой +`SMTP_PORT` — `NaN`, и письма переставали уходить без единого слова в журнале. + +## Зелёная отметка после всех хвостов + +- портал: **293/293, 1008 проверок, ~48 с**; +- робот: **57/57**; +- вызовов снятия заморозки — **четыре**, пятого не появилось. + +## Не начато + +Мелочи — лист v12 §8. Задачи 16 и 17 — только с «go» владельца. + +Побочно закрыта половина **Р-х6**: ключ `REPORT_RETRY_DELAY_MS` добавлен сразу в правильной +форме, но `HUMAN_DELAY_MS` и `SMTP_PORT` по-прежнему с той же миной (`??` ловит только +`undefined`, пустая строка в `.env` даёт 0 и `NaN`) — чинить в Р-х6. + +Отдельно на будущее (из отчёта `rls-reviewer`, в лист v12 не входило): у `crm_app_user` +табличный `UPDATE` на `ad_creative_jobs` без ограничения по колонкам. Сейчас безвредно — +клиентский код в `taken` не пишет, — но защита держится на отсутствии такого кода, а не на +правах в БД. + +Отдельно отмечено при работе (в лист v12 не входило): в `db/CHANGELOG_schema.md` шапка файла +устарела — говорит «тридцать записей … v8.33» и «текущая версия — v8.85», хотя записи ушли до +v9.07. Чинить не в этой ветке. + +## Грабли, пойманные в этой сессии + +- 🪤 `git checkout -- <файл>` после проверки вырезанием **откатывает файл к коммиту**, то есть + сносит и саму правку, а не только мутацию. Возвращать мутацию надо обратной `sed`-заменой, + а `git checkout` использовать только если файл в коммите уже правильный. +- 🪤 `->after('колонка')` в миграции на PostgreSQL — **no-op**, колонка встаёт в конец таблицы. + В проекте так пишут все соседние миграции; в CHANGELOG формулировку уточнил. +- 🔑 Вместо `git checkout` теперь копирую файл в scratchpad перед мутацией и возвращаю + оттуда — правка цела, мутация снята, лишних движений нет. +- 🔑 `pgsql_admin` в тестах **виден**: `tests/Concerns/SharesAdminPdo.php` подключён глобально + к `Feature` (`tests/Pest.php`), обе connection делят один PDO. Поэтому команду сторожа + можно честно прибить к `pgsql_admin`, не подкладывая ей соединение ради тестов. + 🪤 Обратная сторона: мутация «сменить соединение на дефолтное» в тестах НЕ покраснеет — + это чисто боевая защита, держится на комментарии и на знании про RLS. + +--- + +# Мелочи листа v12 §8 — 28.07.2026 + +Порядок — по опасности. Каждая с тестом; где защита уже стояла в коде, тест проверен +вырезанием этой защиты. + +## Кап веса картинки — была тест-пустышка + +Две прежние проверки про предел 512 000 байт сравнивали константу саму с собой +(`BannerUploadPolicyTest`) и с тем же числом в ответе сервера (`CampaignBannerEndpointsTest`). +Вырезание правила `max:` из валидации оставляло обе зелёными — то есть предел не был +проверен ничем. + +Написан настоящий тест: загрузка картинки правильного размера, но тяжелее предела → 422, +строка баннера не создаётся. Проверен вырезанием: без правила `max:` приходит 201. + +Это **четвёртая** найденная пустышка (после захвата кампании, рабочей папки робота и слепка +при выдаче). Общее у всех: тест не отличал «защита работает» от «защиты нет». + +## Обход модерации срывался целиком из-за одной кампании + +`SyncCampaignModerationJob` записывал ответ Яндекса как есть. Два способа положить весь обход: + +- объявление пришло **без статуса** → в колонку летел пустой статус, а она его не принимает; +- **причина отказа длиннее 255 знаков** (модератор перечисляет претензии списком) → не влезает + в колонку. + +Оба падения были ВНЕ try/catch, который прикрывал только поход в сеть. Сорванный обход — это +чужая реклама, про которую никто не узнал, что её приняли или отклонили, и не вернувшиеся +за отклонённый набор деньги. + +Починено: запись ответа теперь под той же защитой, что и сеть (беда одной кампании не трогает +остальных); пустой статус не пишем вовсе — баннер остаётся «не решённым» и держит кампанию +в ожидании; причину храним обрезанной до 255 знаков. + +Два теста, оба были красными ровно по этим двум отказам базы. + +## Робот нёс токен на адрес, присланный сервером + +`downloadBanner` шёл за файлом по адресу из ответа портала и клал в запрос **свой токен**, +не проверяя, что адрес ведёт на портал. Робот живёт на боевом сервере и ходит по внутренней +сети — цена подменённого или просто перепутанного адреса здесь выше обычной. + +Починено: адрес сверяется с `portalBaseUrl` до запроса; чужой адрес и мусор вместо адреса +дают понятную ошибку. Тесты доказывают, что до запроса дело не доходит вовсе. + +🪤 `URL` переводит кириллическое имя хоста в punycode — первое ожидание теста было написано +неверно, и красный оказался не по делу. Хост в тесте латиницей. + +## Папка снимков экрана росла бесконечно + +`screenshots/` не чистилась никогда. Это не просто мусор: на снимке видна боковая панель +кабинета с логином и остатком счёта. Робот убирает снимки старше двух недель при очередном +запуске; уборка молчит при любой беде — из-за неё работа встать не должна. Описано в README. + +## Порядок посредников служебного канала + +Было `['admin-db','creative-robot']`: запрос без токена сначала переключал соединение +на служебную роль и только потом получал отказ. Стало `['creative-robot','admin-db']` — +сначала пропуск, потом ключи от служебного входа (тот же порядок, что у админского канала). + +⚠️ Наблюдаемой разницы в ответе нет — запросов к базе без токена не делалось. Тест держит +порядок от обратной правки и **ничего не гарантирует про поведение**; так и написано в самом +тесте, чтобы его не приняли за защиту. + +## BannerGenerator молча отдавал файл любого веса + +Подбор качества упирался в пол 40 и возвращал что получилось. Кабинет Яндекса такой файл +не примет, и узнали бы мы об этом уже роботом, стоящим перед окном загрузки на боевом. +Теперь — понятный отказ. Заодно убран захардкоженный `512000` в сигнатуре: предел берётся +из `BannerUploadPolicy::MAX_BYTES`, иначе своё число тут разошлось бы с тем, по которому +портал проверяет загрузку клиента. + +## Нулевой номер креатива + +`firstWhere('yandex_creative_id', null)` ловил ноль **случайно** — нестрогим сравнением. +Написали бы строгое «по уму» — и ноль поехал бы в запрос к Яндексу. Теперь ноль ловится +намеренно, рядом с пустотой, и об этом сказано в комментарии. Проверено вырезанием: без +проверки на ноль запуск всё равно останавливается, но уже ПОСЛЕ обращения к Яндексу и +с невнятным для клиента текстом. + +## Повтор одного номера креатива в наборе — закрыто побочно + +Отдельная проверка не нужна: слот «кампания + размер» теперь уникален в базе (v9.09), значит +два баннера набора всегда разных размеров, а сверка настоящего размера креатива перед +созданием объявлений (П4) один из них обязательно поймает. Рассуждение закреплено тестом: +сломается сверка — сломается и он. + +## Закрыто побочно ещё раньше + +`Width`/`Height` без `?? 0` — во всех трёх местах `YandexDirectClient` уже с защитой +(правки П4/П6). + +## ✅ РЕШЕНО ВЛАДЕЛЬЦЕМ 28.07.2026 — цену определяет клиент + +`client_cpm_rub` и `budget_rub` приходят от клиента. В листе v12 это записано как «лучше +не отдавать клиенту», но в мастере кампании стоит **видимое поле «Ваша цена за 1000 показов, ₽»** +(`CampaignWizard.vue`, `data-testid="client-cpm-input"`), и в коде прямо написано: +«Предзаполняем цену дефолтом сервера ровно один раз — дальше клиент правит сам». + +То есть это не недосмотр, а принятое продуктовое решение: клиент назначает свою ставку. +`budget_rub` — счётная величина от сервера, которую клиент видит в списке кампаний. +Убрать их — значит менять продукт и ломать мастер. + +🔴 **Владелец подтвердил 28.07.2026: «клиент сам определяет цену».** Пункт листа v12 §8 +про `client_cpm_rub` и `budget_rub` **закрыт — правку не делать никогда.** Поле цены +в мастере остаётся, ручки `store`/`update` продолжают принимать `client_cpm_rub`. +Если в будущем разборе кто-то снова назовёт это дырой — это НЕ дыра, а решение владельца. + +## Зелёная отметка после мелочей + +- портал: **300/300, 1029 проверок, ~48 с**; +- робот: **60/60**; +- вызовов снятия заморозки — **четыре**, пятого не появилось. + +## Осталось из §8 + +- `isDisabled` у кнопки «Создать» — проверять на первом живом прогоне (задача 17), заранее + никак. +- `CampaignBannerService` и `BannerGenerator` — мёртвый продуктовый код, удалять только + с разрешения владельца. +- `db/schema.sql` без рекламных таблиц и устаревшая шапка `db/CHANGELOG_schema.md` — + отдельный canon-sync, не в этой ветке. + +--- + +# Новая работа: отказы модерации — окно передачи. Заход 1 закрыт 28.07.2026 + +Приёмочный лист этой работы — в замысле +`docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md` §8. +План — `docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md`. +Снимок состояния для следующей сессии — `docs/superpowers/2026-07-28-STATE-okno-peredachi.md`. + +## Сделано — задачи 1–4 + +- **Задача 1** `9c332cc2` — таблица `ad_campaign_messages` и модель. RLS `tenant_isolation`, + гранты `crm_app_user` и `crm_supplier_worker` на SELECT/INSERT плюс нумератор. + Пройден `rls-reviewer`, отчёт перепроверен по коду. Запись **v9.10** в `db/CHANGELOG_schema.md`. +- **Задача 2** `83753fc0` — `CampaignMessageService::postFromYandex()`: защита от дублей + по паре «кампания + баннер», пустой текст не кладём, пишем на том же соединении, + на котором пришла кампания. +- **Задача 3** `70b421e1` — `SyncCampaignModerationJob` кладёт пояснение Яндекса в ленту + **целиком**, в колонке баннера остаётся обрезанное до 255 ради ярлыка. Вызов ленты — + в своём `try`, чтобы беда с лентой не стоила вердикта и возврата денег. +- **Задача 4** `4ddfa7b0` — письмо `AdModerationMessageMail` и колокольчик на каждое новое + слово Яндекса; на служебную отметку `postSystem()` — ни письма, ни колокольчика. + Уведомления целиком под `Throwable`. + +## Проверено вырезанием + +Каждая защита ломалась намеренно, тест обязан был покраснеть, файл возвращался из scratchpad. + +- Убрал проверку дубля в сервисе → покраснел «тот же текст второй раз в ленту не попадает». ✅ +- Убрал внутренний `try/catch` в джобе → покраснел «беда с лентой не срывает вердикт». ✅ +- Нейтрализовал `catch` в `notify()` → покраснел «упавшая почта не мешает лечь в ленту». ✅ + +🪤 **Первая редакция теста про беду с лентой была пустышкой.** Он брал одну принятую +кампанию и проходил одинаково с защитой и без неё: исключение всё равно ловил внешний +`try`, а статус баннера успевал записаться до падения. Настоящая беда другая — обрыв +разбора кампании на первом же баннере: второй остаётся без вердикта, кампания зависает +в ожидании, деньги молча остаются замороженными. Тест переписан на два отклонённых +баннера с проверкой статуса обоих, статуса кампании и возврата заморозки — и только +после этого вырезание покраснело. + +## Зелёная отметка на конец захода 1, коммит `4ddfa7b0` + +Прогнано в одиночку, по одному процессу. + +- портал: **311/311, 1054 проверки, ~51 с** (было 300/300 — добавилось 11 новых тестов); +- робот: **60/60**; +- вызовов снятия заморозки — **четыре**, пятого не появилось. + +## Хвост, записанный сразу + +`crm_admin_user` гранта на `ad_campaign_messages` **не получил** — админского экрана +по ленте пока нет. Когда появится список «ждёт разбора» (кусок 3 замысла), понадобится +догоняющая миграция с `GRANT SELECT` по образцу +`2026_07_27_100100_grant_admin_ad_campaign_banners.php`. 🪤 Без неё админский экран +увидит ноль строк молча, без единой ошибки. Записано в CHANGELOG v9.10. + +--- + +# Заход 2 закрыт 28.07.2026 — кусок 1 «переписка» готов целиком + +Клиент теперь видит, за что Яндекс отклонил его рекламу, и отвечает прямо в портале. + +## Что сделано, задача за задачей + +**Задача 5 — ручка списка сообщений** (`5e5a9c7f`). `GET /api/advertising/campaigns/{id}/messages` +отдаёт ленту своей кампании по порядку. Чужая — 404, без входа — 401. +🪤 Красный был правильный не сразу очевидно: на незнакомый адрес отвечает страница сайта +с кодом 200, поэтому в тестах проверяется содержимое, а не голый `assertOk()`. + +**Задача 6 — ответ клиента с документом** (`27367245`). `POST .../messages` принимает +текст и файл (pdf/jpg/png, до 10 МБ) на закрытый диск; `GET .../messages/{id}/file` +отдаёт файл только своему тенанту. + +**Задача 7 — экран переписки** (`31709d01`). `CampaignMessages.vue` встал в карточку +кампании **вместо мёртвого списка объявлений**: он читал `ad_campaign_ads` — таблицу +старой модели, которую поток «за показы» не заполняет, поэтому клиент всегда видел +«Объявлений пока нет» рядом с красным ярлыком. + +**Задача 8 — подпись под ярлыком** (`958204d2`). `index()` кампаний отдаёт +`moderation_reason`, в списке под чипом «Отклонено» видна первая строка причины. + +**Добор** (`fb63994a`). Отдельный тест на строку приёмочного листа «в переписке нет +наценки»: ручка отдаёт строго перечисленные поля. + +## Что проверено вырезанием + +- Фильтр по тенанту в списке ленты → чужой клиент получил 200 вместо 404. ✅ +- Правила `mimes` и `max:10240` → покраснели оба файловых теста. ✅ +- Условие «пусто и без файла» → покраснел свой тест. ✅ +- Фильтр по тенанту в отдаче файла → покраснел свой тест. ✅ +- Перехват упавшей загрузки ленты → покраснел «упавшая загрузка не роняет экран». ✅ +- Подпись под ярлыком → покраснел «видна первая строка причины». ✅ +- Отдача модели целиком вместо перечня полей → покраснел «в ленте нет наценки». ✅ + +## Три находки, которых не было в плане + +1. 🪤 **План забыл `ensureCsrfCookie()`** в отправке ответа. Все прочие POST'ы модуля + её берут; без неё кнопка «Отправить» молча ломалась бы в браузере при зелёных тестах. +2. 🪤 **Фильтр по тенанту на самом сообщении ничего не сторожит** — его вырезание + оставило всё зелёным, потому что настоящий замок стоит на поиске кампании. Оставлен + вторым ремнём, но доказуемая защита там одна. +3. 🪤 **Диалог отчёта падал, если лента не загрузилась** — поймали существующие тесты + фронта. Теперь упавшая лента пишет строчку и не роняет остальной отчёт. + Заодно вскрылось: наивная отдача модели целиком утекла бы `file_path` — путь файла + на боевом сервере. + +## Приёмочный лист замысла §8, кусок 1 — закрыт целиком + +| Строка | Чем закрыта | +|---|---| +| Пояснение Яндекса появляется в переписке само | `SyncCampaignModerationJobTest` (заход 1) | +| Текст сохраняется целиком, длиннее 255 | `CampaignMessageServiceTest` | +| Повторный опрос второго сообщения не создаёт | `CampaignMessageServiceTest` | +| Клиент видит свою переписку и не видит чужую | `CampaignMessageEndpointsTest` | +| Ответ с файлом, файл на закрытом диске | `CampaignMessageEndpointsTest` | +| Файл отдаётся только своему тенанту | `CampaignMessageEndpointsTest` | +| Не тот тип или тяжелее 10 МБ — 422, на диск ничего | `CampaignMessageEndpointsTest` | +| Письмо и колокольчик на слова Яндекса | `CampaignMessageNotifyTest` (заход 1) | +| Ярлык «Отклонено» показывает первую строку причины | `advertising-campaign-list.spec.ts` | +| В переписке нет наценки | `CampaignMessageEndpointsTest` | + +## Зелёная отметка на конец захода 2, коммит `fb63994a` + +- портал: **322/322, 1090 проверок, ~54 с** (было 311/311 — 11 новых тестов); +- фронт: **227 файлов, 1668 тестов** зелёные (единственная посторонняя ошибка — + в чужом `autopodbor`, к этой работе отношения не имеет); +- `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` (они считают расход по ВСЕМ тенантам) покраснели на ровном +месте. По одному процессу — стабильно зелено. + +--- + +# Заход 4, задача 13 — живая разметка кабинета. Начата 28.07.2026 + +**Кода не написано ни строки** — это наблюдение, и по плану оно идёт до кода. + +## Как вызвали отказ + +Приём подсказал владелец и он же его уже проверил в кабинете МТС 27–28.07: реклама +**регулируемой тематики** (займ под залог) — модерация обязана придраться и потребовать +документы. В МТС это дало и причину отказа, и указание, куда грузить документ. + +Для Яндекса нарисован баннер «ЗАЙМ ПОД ЗАЛОГ НЕДВИЖИМОСТИ. Деньги за 1 час. Без справок +и поручителей» (300×250 и 728×90). Ссылка объявления — `https://liderra.ru`: получается +и регулируемая тематика, и расхождение с деятельностью рекламодателя — те же два повода, +по которым МТС потребовал договор. + +## Что заведено в боевом кабинете + +Отдельная **пустышка**, живую рекламу не трогали: + +| Что | Значение | +|---|---| +| Кампания | № 713110757 «Медийная кампания №3 от 28-07-2026», тип `CPM_BANNER` | +| Срок | **01.10–07.10.2026** — показов сейчас быть не может физически | +| Бюджет / расход | 3 000 ₽ / **0,00 ₽** | +| Группа | № 5777621199, регион Москва, подбор «Профиль пользователя» | +| Объявление | № 17787055204, креатив 300×250, ссылка `https://liderra.ru` | +| Статус на 28.07 | **«На модерации»**, ждём вердикт | +| Живая кампания № 713051718 | не тронута | + +## Три ошибки в НАШЕЙ разметке, вскрытые живой загрузкой + +Разметку снимали 27.07 глазами, файлов не грузили — и два вывода оказались неверными. +Обе поправки внесены в `bots/yandex-creatives/docs/cabinet-flow.md` (§0, §2, §5). + +1. 🔴 **Поле файлов не то.** `CreativeActionsMenu.FileInput` лежит в форме и файлы **молча + забирает**, кнопка «Создать» остаётся серой. Настоящее поле — внутри окна загрузки + (`[data-testid="CanvasModal.html5"] input[type=file]`). Робот по прежней записи встал бы + намертво без единого внятного слова. +2. 🔴 **Загруженный креатив сам в объявление не встаёт.** После «Создать» окно + переключается на «Мои креативы», и набор надо **отметить галочкой** + `input[data-testid="BatchesList"]` (скрытая, нужен `force`), а не нажать соседнюю кнопку + «Выбрать» (`BatchesList.Controls.Select`) — от неё «Добавить выбранные» не оживает. +3. 🪤 **Список объявлений врёт про пустоту** — грид стоит на «Последние 30 дней» и на + кампанию с будущим сроком отдаёт «Нет объявлений», хотя объявление есть. Тот же класс, + что «тихий ноль» под RLS: пусто по фильтру неотличимо от пусто по существу. + +## Ещё две ловушки, стоившие времени + +- 🪤 **Экран после «Запустить кампанию» врёт**: показывает «Черновик» и прячет саму кнопку, + пока формируется описание для ЕРИР. Правду говорит только список кампаний + (`status-filter=ALL`). Я на этом купился и полез запускать второй раз. +- 🪤 **Браузер MCP не тянет длинный мастер**: валится на снимках и вводе + («Target page, context or browser has been closed»), а после падения держит замок профиля. + Лечится убийством **только осиротевшего chrome** этого профиля — сервер трогать не надо. + Для работы поднят свой Playwright на **копии профиля** (скрипты в scratchpad, в репозиторий + не едут): полный контроль, повторы, дампы разметки. + +## Что дальше + +Ждём вердикт (у МТС занял ~4 часа). Как придёт отказ — снять раздел «Экран отклонённого +объявления»: где причина, как выглядит требование документов, куда прикладывается файл, +какой кнопкой уходит повторная модерация. После этого задача 13 закрыта и можно начинать +задачи 14–16. + +**Хвост:** в библиотеке креативов остался один лишний набор от неудачной попытки загрузки. +Ничего не показывает; удалять без владельца не стал. + +--- + +# Задача 14 — вид задания у робота. Сделана 29.07.2026 + +Единственный кусок захода 4, которому вид экрана отказа не нужен: правка таблицы заданий, +экранов кабинета не касается. Взята в работу, пока Яндекс думает над нашей пустышкой. + +## Что сделано + +Робот до сих пор умел ровно одно — отвезти картинки в кабинет, и сама очередь молчаливо +означала именно это. Теперь у задания есть **вид**: + +| Вид | Что означает | +|---|---| +| `upload` | отвезти картинки — то, что робот делает сегодня | +| `inspect` | сходить посмотреть, что кабинет говорит про объявление; ничего не меняет | +| `deliver` | отвезти документ клиента и отправить объявление на повторную модерацию | + +Плюс `message_id` — ссылка на сообщение ленты, документ из которого везём. Без неё робот +у кабинета знает только кампанию, а документов у неё может быть несколько. + +Умолчание `upload` обязательно: задания, лежащие в очереди на момент выката, вида не имеют. + +Файлы: миграция `app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php`, +модель `app/app/Models/AdCreativeJob.php` (три константы, связь `message()`), +три теста в `app/tests/Feature/Advertising/CreativeJobServiceTest.php`. +Журнал схемы — запись **v9.12**. + +🪤 План назначал этой записи номер **v9.11**, но он уже занят отметкой `revived_at` +из захода 3. Поймано сверкой с журналом, а не памятью. + +## Порядок работы + +Тесты написаны первыми и покраснели по правильной причине («нет такой константы»), +а не по опечатке. После зелёного — защита проверена **вырезанием**: убрал поля из +разрешённых к записи, тест «задание может нести вид и ссылку» покраснел с внятным +`ожидалось deliver, получено upload`, поля вернул. + +## 🔴 Главная находка — внешний ключ НЕ защищает от чужого клиента + +Вскрыла проверка прав доступа (`rls-reviewer`), тесты этого не видели и увидеть не могли. + +Проверки ссылочной целостности в PostgreSQL выполняются **в обход RLS**: внешний ключ +подтверждает только, что строка существует, — но не то, что сообщение принадлежит тому же +клиенту и той же кампании. В портале это не выстрелит: там RLS, чужого сообщения клиент +не увидит. А робот ходит под `crm_admin_user`, которому `db/03_service_bypass_policies.sql` +даёт кросс-тенантный доступ — и он **молча увезёт документ ОДНОГО клиента в модерацию +кампании ДРУГОГО**. Ни ошибки, ни красного журнала: утечка персональных данных с зелёными +тестами. + +Сейчас дыра **спящая** — `message_id` в бою никто не записывает, `CreativeJobService::enqueue()` +кладёт только тенанта, кампанию и статус. Она проснётся ровно в задаче 16. + +Требование записано в трёх местах, чтобы не потерялось: докблок миграции, +`db/CHANGELOG_schema.md` v9.12 и приёмочные строки задачи 16 в плане. Суть: сообщение +брать **только связью от кампании** (`$campaign->messages()->findOrFail($id)`), проверяя +и тенанта, и кампанию, — никогда сырым номером из задания. + +Гарантию на уровне базы дал бы UNIQUE `(id, tenant_id)` плюс составной внешний ключ. +Сознательно не делаем: при составном ключе `ON DELETE SET NULL` обнулил бы и `tenant_id`, +который NOT NULL, а точечный вариант PostgreSQL 16 Laravel сам не умеет. Та же дыра уже +есть у `campaign_id` и держится тем же способом — загрузкой моделью под RLS. + +Второй хвост оттуда же: `crm_admin_user` не имеет `GRANT SELECT` на `ad_campaign_messages`. +Миграция без него работает (ключ проверяет система), но робот, начав читать сообщение, +увидит ноль строк молча. Догоняющий грант — в задаче 16. + +## Числа после задачи 14 + +- портал: **345/345, 1139 проверок, 63 с** +- робот: **60/60** +- мест снятия заморозки денег — **четыре**, без изменений +- `pint` по своим файлам — чисто + +## Состояние кампании-пустышки на 29.07 + +Вердикта **всё ещё нет**: кампания № 713110757 и объявление № 17787055204 — «На модерации», +расход 0,00 ₽. Живая кампания № 713051718 идёт своим ходом. + +Полезное, снятое попутно: у ярлыка статуса устойчивая метка вида +`NavigationItem.Banner.<номер>.StatusLabel.MODERATION` и `EntityStatus.StatusLabel.MODERATION`. +Роботу не придётся угадывать статус по тексту на экране — метка сама называет состояние, +и смена на отказ будет видна по ней. Дописать в разметку вместе с экраном отказа. + +🪤 В кабинете висит предложение Яндекса «овердрафт до 31 000 ₽» — реклама в минус с оплатой +в следующем месяце. Не подключали. Если когда-нибудь подключить, наша защита «не тратим +больше замороженного» перестанет быть последней преградой. + +--- + +# Закрытие находок 29.07.2026 — пока ждём вердикт + +Три косяка, вскрытых проверкой прав доступа по задаче 14. Все закрыты, ни один не отложен. + +## 1. 🔴 Утечка чужого документа — закрыта на уровне базы, не дисциплины + +Дыра описана в разделе задачи 14. Сначала я записал её как «закрываем проверкой в коде +задачи 16, база помочь не может». **Это было неверно, и хорошо, что проверил.** PostgreSQL 16 +умеет точечный `ON DELETE SET NULL (колонка)` — значит составной внешний ключ ставится, и +чужой документ отказывает сама база. + +Закрывали в два приёма, и второй понадобился именно потому, что первый я переоценил: + +- **v9.13** — ключ `(message_id, campaign_id)`: документ обязан принадлежать той же кампании. + Я записал, что это «закрывает оба случая разом». **Проверка показала, что нет.** +- **v9.15** — ключ `(campaign_id, tenant_id)` на заданиях и на ленте: клиент задания обязан + совпадать с клиентом кампании. Без него база принимала строку «задание клиента A на кампанию + клиента B», и гарантия v9.13 висела на допущении, которого никто не проверял. + +🔑 **Урок, повторяющийся третий раз за две недели: моя формулировка защиты оказалась сильнее +самой защиты.** Оба раза это ловил не тест, а разбор прав доступа — тест проверял ровно то, +что я задумал, а не то, чего я не заметил. + +Дыры воспроизведены вживую **до** правок: база принимала и чужой документ, и чужого клиента. +Проверка в коде задачи 16 остаётся обязательной вторым рубежом. + +Побочный выигрыш v9.15: закрыт тихий боковой канал. Задание с чужим `tenant_id` было видно +клиенту A по RLS, а робот пишет в него причину провала — попади туда слова модератора чужой +кампании, это капля чужих данных в портал. + +## 2. Недостающее право чтения ленты — выдано + +**v9.14**: `GRANT SELECT ON ad_campaign_messages TO crm_admin_user`. Без него служебный канал +робота и админский экран «ждёт разбора» увидели бы ноль строк **молча**. Только SELECT: писать +в ленту служебному каналу незачем. + +🪤 **И здесь я написал неправду, пойманную проверкой:** «перезапускать +`db/03_service_bypass_policies.sql` не нужно». Нужно, и обязательно — не из-за гранта, +а из-за самих новых таблиц `ad_campaign_messages` и `ad_creative_jobs`. Политику `srv_bypass` +не ставит ни создание таблицы, ни миграция: только ручной прогон файла. Это ровно та мина, +на которой уже подрывались. + +## 3. Устаревшая шапка журнала схемы — поправлена + +Держала счётчик «тридцать записей» при полутора сотнях. Счётчик убран. 🪤 Заодно я написал, +что и номер версии там неверный, — **это тоже оказалось неправдой**: v8.85 со `schema.sql` +совпадала. Формулировка исправлена. Добавлена оговорка, которой не хватало: рекламные +записи v9.x в `schema.sql` не консолидированы вовсе, идти туда за версией v9.x бесполезно. + +## Числа + +- портал: **350/350, 1144 проверки**, в том числе на **пересозданной с нуля базе** +- робот: **60/60** +- мест снятия заморозки денег — **четыре**, без изменений +- все три миграции проверены вверх-вниз-вверх и накатом на пустую базу + +## Что это меняет для выката + +🔴 В выкате этого куска **обязателен ручной перезапуск `db/03_service_bypass_policies.sql`**. +Без него служебные роли увидят в `ad_campaign_messages` и `ad_creative_jobs` ноль строк молча, +а журнал будет зелёный. + +🔴 Записи **v9.13, v9.14 и v9.15 выкатываются только вместе**. Грант открывает роли чтение +ленты целиком; тем, что робот не возьмёт чужое, занимаются оба составных ключа. + +## 4. Второй рубеж защиты документа — тоже закрыт, хвостов не осталось + +Оставлять «проверку в коде» на задачу 16 было бы хвостом: сама проверка от экранов +кабинета не зависит ни капли. Написана сразу. + +`CreativeJobService::enqueueDelivery($campaign, $messageId)` — единственный путь постановки +задания «отвезти документ». Сообщение берётся **только связью от кампании** +(`$campaign->messages()->findOrFail()`), сырой номер в выборку не попадает нигде. Добавлена +связь `AdCampaign::messages()` с красной пометкой, что искать сообщение номером нельзя. + +Заодно метод отказывается ставить задание, когда в сообщении нет вложения, и не плодит +второе задание, если клиент нажал дважды. + +### 🪤 Ловушка, которую заложила сама задача 14 — поймана здесь + +Постановка обычной заливки искала «любое незавершённое задание кампании». Пока вид был один, +это однозначно означало заливку. С появлением доставки тот же запрос вернул бы **задание +с документом** — запуск решил бы, что креативы уже в очереди, и робот не повёз бы картинки +вовсе. Молча, при зелёном журнале. Отбор по виду добавлен, на ловушку есть отдельный тест. + +Найдено не проверкой и не тестом, а чтением соседнего метода перед тем, как дописывать рядом. + +### Оба рубежа доказаны вырезанием, по отдельности + +Вырезал проверку в коде — подменил связь на поиск по сырому номеру. Тесты покраснели, +но **не так, как раньше**: вместо «документ не найден» пришла ошибка записи от PostgreSQL. +То есть чужой документ поймали **ключи базы**. Значит работают оба рубежа, и работают +независимо: код отказывает рано и понятно, база — если кто-то однажды напишет мимо кода. + +### Числа + +- портал: **356/356, 1156 проверок** +- робот: **60/60** +- мест снятия заморозки денег — **четыре** + +### Что осталось задаче 16 — и это уже не про защиту + +🪤 Служебная отметка в ленту от имени робота потребует ещё прав: `crm_admin_user` имеет +на ленте только чтение. Либо писать отметку порталом, либо догоняющей миграцией выдать +запись и права на нумератор. Записано в приёмочных строках задачи 16. + +--- + +## Задача 13 — вердикт Яндекса пришёл, экран отказа снят (28.07.2026, вечер) + +Ждали двое суток. Пустышка № 713110757, объявление № 17787055204 — модерация отклонила: +**«Модератор отклонил объявление · Нет предупреждения: финансовые услуги»**. + +Разметка записана в `bots/yandex-creatives/docs/cabinet-flow.md` §7, снимок окна отказа — +в `docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/`. Живая кампания владельца +№ 713051718 не тронута, в кабинете ничего не менялось: только чтение и раскрытие окна. + +### Как искали — и почему сразу не нашли + +Форма объявления показала `STOP_CRIT` «Показы не идут» и **ни слова про модерацию**. +Список кампаний — «Нет готовых к показу объявлений». Ни там, ни там причины нет. +Причина нашлась в **списке объявлений**: клик по `AggregatedStatusContent` открывает окно +`BannerStatusCell.Popup`, внутри строка `RejectRow`. Наведение мышью окна не открывает. + +А подробное пояснение спрятано ещё на слой глубже: строку причины надо **раскрыть**, +иначе виден только короткий заголовок. Первый заход дал «Нет предупреждения: финансовые +услуги» и всё; шеврон на снимке экрана подсказал, что там есть продолжение. + +🪤 **Список объявлений соврал второй раз, теперь наоборот.** Во время модерации он отдавал +«Нет объявлений по заданным параметрам» (мы это записали 28.07 утром как §5.4), после +отказа — показал то же объявление при том же фильтре. Механику не выясняли. Рабочий вывод +прежний и усиленный: **пустой список ничего не доказывает**, номер объявления проверять +отдельно. + +### 🔴 Два отрицательных ответа — они важнее найденного + +1. **Кнопки «отправить на повторную модерацию» не существует.** В подвале формы только + «Сохранить изменения». Яндекс пишет прямо: «Добавьте предупреждение, и объявление + автоматически отправится на повторную проверку». +2. **Приложить документ в кабинете НЕКУДА.** В окне отказа ноль полей для файла — проверено + программно, список полей пуст. Вместо этого две кнопки, обе уводят **наружу**: чат + Яндекса и форма обратной связи `forms.yandex.ru`. + +Второй пункт бьёт по замыслу куска 3: задача 16 предполагала, что робот отвезёт документ +в кабинет. Такой дороги нет. Задача 16 остановлена, решение — за владельцем. + +Оговорка, чтобы не выдать частный случай за правило: наш отказ был «поправьте креатив», +а не «пришлите лицензию». У лицензируемых тематик окно может быть другим — проверяется +только вторым нарочным отказом, это ещё сутки-двое. + +### Заодно поправлены даты + +Работа прошлого захода была помечена «29.07.2026» — ошибка в календаре, всё делалось +**28.07**. Тексты поправлены; имена миграций `2026_07_29_*` оставлены как есть — +они закоммичены, а дата в имени задаёт только порядок применения. + +### Числа на этот момент + +- портал: **356/356, 1156 проверок** +- робот: **60/60** +- мест снятия заморозки денег — **четыре** + +--- + +## 🔴 Находка дня: Яндекс не говорит машине, за что отклонил (28.07.2026) + +Собирался писать разведку робота и решил сперва проверить очевидное: а не отдаёт ли +Яндекс причину отказа **программно**? Если отдаёт, робот-разведчик не нужен вовсе, +и целая задача отпадает. Владелец разрешил один запрос на чтение боевым ключом. + +Ответ на отклонённое объявление: + +```json +{ "Id": 17787055204, "Status": "REJECTED", "StatusClarification": "\nОтклонено на модерации." } +``` + +Всё. На экране в этот же момент — «Нет предупреждения: финансовые услуги» и абзац с +указанием, что именно дописать в баннер. Машине — ни слова. В подполях (`Creative`, +`TurboPageModeration`) тоже пусто; по документации у медийного объявления +`StatusClarification` вообще встречается только в модерации турбо-страницы. + +### Что это меняет + +**Задача 15 из «удобства» стала обязательной.** Без робота портал знает только факт +отказа. Хорошо, что проверил до того, как писать код: вывод мог оказаться и обратным. + +### 🔴 И вскрылся скрытый дефект в куске, который считался готовым + +Кусок 1 «переписка» закрыт, тесты зелёные — а на живых данных клиент увидит пустоту: + +- **в переписке** от Яндекса придёт единственная фраза «Отклонено на модерации.»; +- **в списке кампаний под ярлыком «Отклонено» не будет ничего**: подпись берётся как + первая строка причины, а настоящий текст Яндекса начинается с переноса — первая + строка пустая, и блок скрывается совсем. + +🪤 **Почему не поймали.** Тесты подставляют выдуманный текст причины, и на нём всё +работает как задумано. Дефект живёт ровно в зазоре между выдуманными данными и живыми. +Тот самый класс «скрытых косяков, которые потом хрен найдёшь». + +🪤 **Урок, повторившийся за день дважды.** Сначала экран объявления оказался немым про +модерацию, теперь немым оказался программный интерфейс. Оба раза источник, который +считался говорящим, на живых данных молчал. Проверять надо не «работает ли обращение», +а **что именно приходит на настоящих данных**. + +Дефект записан, но НЕ починен — чинится независимо от вердикта модерации. + +--- + +## Дефект «клиент не узнаёт причину отказа» — ПОЧИНЕН (28.07.2026) + +Владелец выбрал: говорить клиенту честно «отклонили, выясняем причину». + +### Что было + +Яндекс на отказ отдаёт машине «\nОтклонено на модерации.» и всё. Из этого выходило три +беды разом, и все три в куске, который считался готовым: + +1. в переписке клиент видел единственную фразу «Отклонено на модерации.»; +2. в списке кампаний под ярлыком «Отклонено» — **пусто**: подпись берётся как первая + строка, а первым знаком идёт перенос; +3. письмо клиенту повторяло текст из переписки, то есть ту же бесполезную фразу. + +### Что стало + +Новый `app/app/Services/Advertising/ModerationReason.php` — одно место, где ответ +модерации превращается в текст для клиента. Два метода: `normalize` обрезает края, +`forRejected` подменяет отписку на честное «Яндекс отклонил рекламу, но причину +не назвал. Выясняем — как только узнаем, напишем здесь». + +Первая строка этого текста короткая нарочно: именно она идёт подписью под ярлыком. + +Письмо починилось само — оно берёт текст из переписки. + +### 🪤 Ловушка, пойманная до того, как была написана + +`forRejected` нельзя звать на все объявления подряд: у **принятого** пустое пояснение +это норма, и подмена приписала бы принятой рекламе отказ. Подмена вызывается только +при статусе `REJECTED`, а на ловушку поставлен отдельный тест-сторож — он был зелёным +сразу и покраснел бы, задень я его правкой. + +### Второй слой — на экране + +`firstLine` в `CampaignList.vue` теперь обрезает края **до** разбора на строки, а не +после. Тест с настоящим текстом Яндекса до правки краснел на том, что блока причины +нет вовсе. + +Два слоя не подпирают друг друга: сервер решает, ЧТО сказать клиенту, экран — как это +не потерять. + +### Проверено вырезанием + +Убрал подмену — два теста покраснели ровно на том, что клиент снова получает «Отклонено +на модерации.». Тест про обрезку переносов при этом остался зелёным: обрезка и подмена +разделены, у каждой свой сторож. + +### Числа + +- портал: **361/361, 1166 проверок** (было 356 — пять новых тестов) +- фронт, наборы рекламы: **37/37** +- робот: **60/60** +- мест снятия заморозки денег — **четыре** + +--- + +## Вердикт по второй пробе модерации — окно отказа то же самое (28.07.2026) + +Ждали ради одного вопроса: у лицензируемой тематики окно отказа другое, есть ли там поле +для документа? **Нет.** + +Объявление № 17787102785 («Стоматология · Имплантация зубов») отклонено: +**«Нет предупреждений: медицина»**. Разметка совпала с первым отказом до метки: +`BannerStatusCell.Popup` → `MiniGrid` → `RejectRow` → `MiniGridRow` → +`AggregatedStatusAction`. Список полей окна пуст, кнопки те же две — «Написать в чат» +и «Написать письмо», обе наружу из кабинета. Снимок — +`docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-medicina.png`. + +⚠️ **Честно про границы вывода: экрана «пришлите лицензию» мы так и не увидели.** Медицину +Яндекс отклонил снова за отсутствие предупреждающей надписи, а не за отсутствие бумаги. +Доказано другое, но не менее полезное: **лицензируемая тематика сама по себе окна +с документом не открывает.** + +🔑 **Обоим отказам лечение одно — переделать картинку.** Ни первому, ни второму документ +не помог бы. Дороги «отвезти документ роботом в кабинет» не существует; решение, что делать +вместо задачи 16, — за владельцем. + +--- + +## 🔴 Дефект в роботе, найденный чтением перед задачей 15 (28.07.2026) + +Собирался писать разведку и перечитал `cabinet.js` — а он грузит файлы в поле +`CreativeActionsMenu.FileInput`. Это ровно то поле, про которое 28.07 утром записана +поправка: Яндекс положенные туда файлы **не принимает**, кнопка «Создать» остаётся серой. + +**Робот живьём не смог бы залить ни одной картинки.** Падал бы честно +(«кабинет не принял файлы»), но каждый раз. Поправка разметки была внесена в +`cabinet-flow.md`, а до кода не доехала — тест при этом закреплял НЕВЕРНОЕ поле. + +Заодно из той же поправки вылезло второе: окно после «Создать» живьём **не закрывается**, +а переключается на вкладку «Мои креативы» со списком наборов. Робот считал это бедой +и слал владельцу письмо-алярм на КАЖДОЙ удачной загрузке — плюс стоял две минуты, ожидая +закрытия, которого не бывает. Теперь признак успеха — появившийся список наборов; +человека зовём только когда исход непонятен: ни списка, ни закрытия. + +Обе починки проверены вырезанием по отдельности. Коммит `7a446132`. + +🪤 **Урок дня:** поправка живой разметки обязана доезжать до КОДА в тот же заход. +Документ поправили, тест остался сторожить старую ошибку — и сторожил бы её вечно. + +--- + +## Задача 15 — разведка роботом (28.07.2026) + +Что теперь умеет портал и робот: + +1. Опрос модерации видит отказ → ставит роботу задание **разведки** по этому объявлению. +2. Робот открывает **список объявлений** (не форму — форма про модерацию не знает), + находит ячейку СВОЕГО объявления, кликает по статусу, раскрывает строку причины, + читает текст и снимает **одно окно** (не страницу: на полном снимке видна панель + с логином и остатком счёта). +3. Доклад уезжает порталу формой вместе со снимком; портал кладёт его в ленту от имени + `yandex` **слово в слово** и шлёт клиенту письмо с колокольчиком. +4. Робот не понял, что видит — задание сбойное, владельцу письмо, **в ленту клиенту + ничего не сочиняем**. +5. В админке список «ждёт разбора» — `/api/admin/advertising/robot-stuck`, все сбойные + задания робота, а не только разведка: сорванная заливка — тоже вставшая кампания. + +**Что поймано по дороге:** + +- 🔴 **Дедуп разведки нельзя вешать на кампанию.** Отказ никуда не девается, обход бежит + по расписанию — «нет незавершённого задания» ловит только соседний обход, а после + закрытия первой разведки поставилась бы вторая, и робот ходил бы в кабинет по кругу. + Ключ — **номер объявления** (журнал схемы v9.16): он не меняется, пока не меняется беда, + а после «Исправить» объявление заводится заново с новым номером. +- 🪤 **Слепок креативов снимался при выдаче ЛЮБОГО задания** — сетевой вызов в живой Яндекс + внутри открытой транзакции. Разведке он не нужен вовсе. +- 🔴 **Рубильник держал не выдачу, а построение клиента Директа.** Проверка сидела внутри + похода за слепком — то есть при выключенном рубильнике разведка получила бы задание + и робот пошёл бы в живой кабинет. Проверка поднята в саму выдачу. +- 🪤 **Дедуп ленты сравнивал только с ПОСЛЕДНИМ сообщением Яндекса.** Порядок в жизни: + обход кладёт «причину выясняем» → робот приносит настоящую причину → следующий обход + снова кладёт «причину выясняем», ведь последним теперь лежит доклад робота. Клиент, + уже прочитавший причину, получал бы поверх неё письмо «Яндекс причину не назвал». + Теперь сравниваем со всей прошлой перепиской по этому баннеру. +- Доклад разведки принимает **отдельная ручка**, а не «готово»: у разведки другой смысл + готовности — без текста её не бывает. Пустой доклад отклоняется, задание остаётся в работе. + +**Проверено вырезанием:** дедуп разведки, проверка «только отклонённым», поле файлов +робота, письмо-алярм на удачной загрузке. Каждый раз краснел ровно свой тест. + +### Числа + +- портал: **381/381, 1221 проверка** (было 361) +- робот: **75/75** (было 60) +- мест снятия заморозки денег — **четыре** + +### 🔴 Четвёртая ловушка — её поймал rls-reviewer, а не я + +Постановка разведки работала на дефолтном соединении **без tenant-контекста**. Зовёт её +джоб опроса модерации — он бежит в очереди под ролью `crm_app_user` (НЕ BYPASSRLS) и +контекста не выставляет. На бою это дало бы: + +- поиск дубля — **ноль строк всегда** (политика при пустом контексте даёт `tenant_id = NULL`); +- запись — `new row violates row-level security policy`, прямо в `Log::warning` внутри + `try/catch`, то есть **молча**, при зелёном журнале и зелёных тестах. + +**Разведка не ставилась бы никогда.** Клиент видел бы отказ и вечное «причину выясняем». + +🪤 **Тесты этого не ловят по построению** — они ходят суперпользователем `postgres`, +которому права и RLS не проверяются. Ровно тот же класс, что v9.05 «permission denied +for sequence». Поэтому сторож поставлен не на результат, а на **механизм**: тест ловит, +что `SET LOCAL app.current_tenant_id` выполняется и с нужным номером клиента. + +Лечение — своя транзакция + `SET LOCAL`, рецепт `ChargeCampaignSpendJob`. Новых грантов +не потребовалось: у `crm_app_user` есть и права на таблицу, и нумератор. + +🔑 **Показательно, что соседний путь того же обхода грабку уже обходил** — +`CampaignMessageService` кладёт сообщение соединением кампании и прямым текстом пишет +об этом в докблоке. Я написал новый метод рядом и не перенёс защиту. Читать соседа +перед тем, как писать рядом, — правило, которое я нарушил второй раз за два дня. + +Числа после починки: портал **382/382, 1222 проверки**. + +--- + +## Задача 16 закрыта решением владельца: говорим клиенту правду (28.07.2026) + +Дороги «робот везёт документ в кабинет» не существует — доказано двумя нарочными отказами. +Владелец выбрал: приём документа оставить, но сказать клиенту как есть. + +Что сделано: + +- клиент приложил файл → в ленту сразу ложится служебная отметка: **документ у нас, + передать его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь**; +- владельцу уходит письмо `AdDocumentAttachedMail` на `services.monitoring.alert_email`. + 🔑 Без письма «отнесём сами» было бы **пустым обещанием**: файл просто лёг бы на диск, + и о нём никто бы не узнал; +- сам файл письмом НЕ отправляем — это чужие бумаги (лицензии, свидетельства). В письме + только номер кампании, имя файла и комментарий клиента; +- отметка и письмо под `try/catch`: беда с почтой не должна возвращать клиенту отказ + на успешно принятый документ. Есть тест-сторож. + +Обычный ответ без файла ни отметки, ни письма не порождает — иначе владельца дёргали бы +на каждую реплику клиента. + +## Экран «ждёт разбора» в админке (28.07.2026) + +Ручка была, экрана не было — нарисован третьей карточкой на странице «Реклама» в админке. +Показывает клиента, кампанию, что робот делал (человеческими словами, не `inspect`), +номер объявления в Яндексе и на чём споткнулся. Пусто — говорит об этом прямо, +а не показывает пустую таблицу. + +🔑 В подписи к экрану прямо сказано, чего там НЕ будет: обычных отказов модерации. +Их клиент разбирает сам, причина лежит у него в переписке. Экран — только про обрывы +цепочки, иначе он превратится в свалку и его перестанут смотреть. + +### Числа на конец захода 4 + +- портал: **386/386, 1232 проверки** +- экраны рекламы и админки: **35/35** на затронутых наборах +- робот: **75/75** +- мест снятия заморозки денег — **четыре** + +--- + +## 🔴 Разбор собственной работы: четыре ошибки, найденные перечитыванием (28.07.2026, ночь) + +Владелец сказал остановиться и посмотреть, что сделано. Посмотрел — нашёл четыре штуки, +одна тяжёлая. Все четыре относятся к коду, написанному в этот же день. + +### 1. Доклад разведки уронил бы очередь целиком + +Робот пишет в ленту под ролью `crm_admin_user` — маршруты робота идут через посредник +`admin-db`. У этой роли на `ad_campaign_messages` было **только чтение** (v9.14). +На бою: `permission denied` → 500 роботу → три повтора → сдался. **Задание навсегда +«в работе», а пока хоть одно задание в работе, выдача отвечает «работы нет» ВСЕМ +клиентам — очередь встала бы колом для всего продукта.** + +Обиднее всего, что в плане это было записано прямым текстом как «хвост задачи 16», и я +прошёл мимо. Лечение — запись **v9.17**: INSERT + нумератор. + +🪤 Тесты не поймали и не могли: ходят суперпользователем, права не проверяются. + +### 2. Признак «набор создан» я выдумал + +Взял метку `BatchesList` как признак удачной загрузки. А в нашей же разметке (§5.2) +написано: это **скрытая галочка**, её жмут через `force`. Проверка «видно ли её» +не сработала бы никогда — вернулся бы ровно тот алярм на каждой загрузке, который +я этой правкой и чинил. Плюс строгий режим Playwright упал бы на нескольких совпадениях. + +**Признак с экрана убран совсем.** «Окно не закрылось» объявлено нормой (так и есть +живьём), человека по виду окна не зовём. Настоящий сбой ловит портал слепком +`creatives.get` — не появился креатив, задание закрывается сбоем с внятной причиной, +и оно видно в админке «ждёт разбора». Экранный сторож не нужен. + +🪤 Урок ровно тот же, что я сам записал утром: **не гадать по экрану**. Записал — и через +несколько часов сделал. + +### 3. Сломал ленту для повторного отказа + +Поменял защиту от дублей на «такой текст уже когда-либо был». Выглядит надёжнее, а ломает +главный случай: клиент починил рекламу, отправил заново, получил ТОТ ЖЕ отказ — и такое +сообщение не показалось бы вовсе. Клиент остался бы без объяснения, почему опять не пустили. + +Вернул сравнение с последним сообщением. Заглушку «причину выясняем» держит теперь не дедуп, +а сам джоб: показываем её один раз, пока сказать нечего. + +### 4. Мой собственный тест оказался ПУСТЫШКОЙ + +Тест «после доклада разведки заглушка не возвращается» был зелёным и **оставался зелёным +при вырезанной защите**. Причина: в тесте отклонялись ВСЕ объявления кампании, а тогда +кампания уходит в «отклонена», и обход её больше не берёт — второго прохода просто +не случалось. + +Сценарий существует только при ЧАСТИЧНОМ отказе: одно объявление принято, одно отклонено, +кампания продолжает работать. Тест переписан на две штуки — теперь вырезание защиты +его роняет. + +🪤 Это второй раз за неделю: тест проверял состояние, в которое продукт не попадает. +**Вырезание защиты — единственный способ отличить сторожа от украшения.** + +### Числа после разбора + +- портал: **391/391, 1244 проверки** +- админские экраны: **9/9** +- фронт на затронутых наборах: **52/52** +- робот: **74/74** +- мест снятия заморозки денег — **четыре** + +--- + +## 🟢 Живой прогон разведки: две поломки, невидимые для 74 зелёных тестов (28.07.2026) + +Владелец разрешил прогнать разведку живьём. Гоняли **саму функцию робота** +`readRejection()` против боевого кабинета, на обоих отклонённых объявлениях пустышки +№ 713110757. Живую кампанию № 713051718 не трогали, робот только читал. + +### Поломка 1 — робот не нашёл объявление, которое там было + +Первый запуск: **оба раза провал за 3 секунды**, «объявления в списке кабинета нет». +Объявления при этом на месте. + +Замерил, а не гадал: считал ячейки раз в секунду. Список рисуется за **2 секунды**, +а робот считал сразу после человекоподобной паузы — по умолчанию **800 мс**. +`count()` не ждёт: он отвечает про «прямо сейчас». + +На бою это значило бы, что **каждый** отказ уезжает в «ждёт разбора», а клиент причину +не узнаёт никогда. Разведка не работала бы вообще — при зелёном журнале. + +### Поломка 2 — доклад из одного заголовка + +После первой починки робот стал находить объявление и приносить **29 знаков**: +«Модератор отклонил объявление». Причины нет. + +Причина та же самая: строка `RejectRow` появляется ПОЗЖЕ окна, робот считал её +через `count()`, получал ноль и раскрывать не пытался. Клиент получил бы письмо +от Яндекса, в котором нет ни слова о том, что чинить. + +🪤 **Текст окна нарастает по частям: 29 знаков → 82 → 785.** Окно не отдаёт ошибку — +оно честно показывает то, что успело нарисоваться. Поэтому промах молчаливый: +робот считал бы, что справился. + +### Починка и проверка + +Одна на обе: **ждать, а не считать**. Раскрытие строки подтверждаем появлением +подробности `MiniGridRow`, а не паузой — пауза это надежда, элемент это факт. +Не дождались подробности — остаёмся с короткой причиной: она честная и клиенту +полезна, промолчать было бы хуже. + +Оба сторожа написаны ДО починки и падали с теми самыми живыми ошибками. Робот **77/77**. + +Перепроверено живым кабинетом с продовой паузой 800 мс: причины приехали целиком — +**792 знака** (финансовые услуги) и **621** (медицина), 6–7 секунд на объявление. +Снимок берётся только с окна: логин и остаток счёта в кадр не попадают. + +Замеры записаны в разметку — `bots/yandex-creatives/docs/cabinet-flow.md` §7.8. + +### Что из этого следует + +🔴 **В этом кабинете `count()` применять нельзя вообще** — только `waitFor` с запасом. +Обе поломки дня ровно это. + +🔑 Разрыв «разметка записана — программа по ней ходит» обнаружился **третий раз +за два дня**, и снова его не увидел ни один зелёный тест. Живой прогон нашёл +за десять минут то, чего 74 теста не видели вовсе. Никакое число зелёных тестов +не заменяет одного живого запуска. + +### Осталось открытым + +- Отказ **с несколькими** причинами живьём не встречался: раскрывается только первая + строка. Чинить вслепую нельзя — неизвестно, не сворачивает ли раскрытие второй + строки первую. +- В хвосте доклада клиенту едут подписи кнопок «Написать в чат» и «Написать письмо» — + это кнопки нашего кабинета поставщика, клиенту бесполезны. Обрезать или оставить — + решать владельцу. + +### Хвост доклада срезан — и вскрыл третью ловушку + +Владелец решил: подписи кнопок «Написать в чат» и «Написать письмо» клиенту не показывать. +Это кнопки нашего кабинета поставщика, у клиента их нет, а выглядят они приглашением +написать Яндексу. + +Срезаем только ХВОСТ и только точное совпадение строки: те же слова внутри пояснения — +слова Яндекса, их не трогаем. + +🪤🪤 **Первая же живая проверка показала: «Написать письмо» срезалось, а «Написать в чат» +оставалось.** Причина — Яндекс ставит между короткими словами **неразрывный пробел** +(код 160). Глазу он неотличим от обычного, а точному сравнению — совсем другой символ. +Видно и в соседней строке кабинета: «Показы не·идут». + +🔑 **Правило: любое сравнение текста из этого кабинета делать по «человеческому» виду +строки** — все пробельные подряд считать одним обычным. Иначе сравнение врёт на ровном +месте и молча. + +Попутно замечено: кнопки `AggregatedStatusAction` появляются в окне только ПОСЛЕ раскрытия +строки причины — ещё одно подтверждение, что окно наливается по частям. + +Робот **80/80**. Живьём проверено на обоих объявлениях: причины целы, кнопок нет — +760 знаков по финансовым услугам и 589 по медицине. + +🪤 Грабля дня из своих: правил снимок состояния скриптом на Python — он открыл файл +на запись и упал на эмодзи, оставив НОЛЬ байт. Спасло то, что файл был заранее отобран +в индекс git. Для правки текстовых файлов с эмодзи скрипты не нужны, есть обычный +редактор. diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-reklama-pokazy-v5.md b/docs/superpowers/2026-07-27-PROMPT-restart-reklama-pokazy-v5.md new file mode 100644 index 00000000..3df8a6c7 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-reklama-pokazy-v5.md @@ -0,0 +1,123 @@ +# ПРОМТ перезапуска сессии — реклама «за показы», после закрытия заморозки (27.07.2026) + +Скопировать содержимое блока ниже целиком в новую сессию. + +--- + +``` +Ты — Claude Code в проекте Лидерры. 🔴 БОЕВОЙ ПРОД liderra.ru / lk.liderra.ru — ЖИВЫЕ +КЛИЕНТЫ И ДЕНЬГИ. Любой выкат, правка боевой БД, включение рубильников — ТОЛЬКО с +явного разрешения владельца. БД по умолчанию только чтение. + +🔴 ОБЯЗАТЕЛЬНО инвокируй подходящие скилы ПЕРВЫМИ (Pravila §12 hard-rule — skill +вызывается ДО работы, §9 «Отступления» к нему не применяется). Не «на глаз» без скилов. +Разговор с владельцем — простым русским, без программистских слов: он не программист. + +════════ СНАЧАЛА ПРОЧИТАЙ ════════ +1. 📌 ГЛАВНОЕ — состояние стройки: docs/superpowers/2026-07-27-HANDOFF-reklama-pokazy-chast4-yadro-STATE.md +2. Дизайн и решения по клиентскому Яндексу: + docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-РЕШЕНИЯ.md + docs/superpowers/specs/2026-07-24-yandex-audience-dlya-klientov-design.md +3. Хвосты прошлого слоя (реклама за клики, уже на бою): + docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md + docs/superpowers/plans/2026-07-25-yandex-reklama-ux-audit-fixes.md +4. Контракт медиа-API Директа: docs/superpowers/findings/2026-07-26-direct-media-api-contract.md + (раздел «⚠️ Проверить при go-live») +5. CLAUDE.md §0/§1/§3/§5 + docs/Plugin_stack_rules_v1.md (Vue 3 + Vuetify 3, Forest-палитра). +6. Память: project-reklama-modul-vykat-2026-07-25, feedback-worktree-laravel-windows, + feedback-brauzer-lovit-to-chego-ne-vidyat-testy, feedback_environment. + +════════ ГДЕ ЧТО ЛЕЖИТ ════════ +• Рабочая папка стройки: .claude/worktrees/reklama-pokazy, ветка feat/reklama-yandex-pokazy, + HEAD = dd894a52, ЗАПУШЕНА в gitea, незапушенных коммитов 0. +• main = a487c660 = gitea/main = боевой сервер БАЙТ В БАЙТ (598 файлов бэкенда сверены 27.07). +• 🔴 GitHub origin ЗАБЛОКИРОВАН (account suspended, 403) — пуш ТОЛЬКО в gitea. +• 🪤 Основная рабочая папка стоит на чужой ветке fix/warming-existing-manager-dedup + (8 незапушенных коммитов по телеграм-боту МТС + 255 новых файлов) — НЕ ТРОГАТЬ. +• 🟠 Ещё 6 веток нигде не сохранены, только на этой машине: feat/client-sms-broadcast 15, + fix/warming-existing-manager-dedup 8, feat/aleksandra-knowledge-base 86, + feat/external-services-online-monitoring 47, feat/extsvc-followup-2 60, + backup/before-pdn-clean-20260719 172. Предложить владельцу выгрузить в gitea. + +════════ СОСТОЯНИЕ НА 27.07.2026 ════════ +СЛОЙ 1-2 — реклама «за клики» + UX-правки: ВЫКАЧЕНЫ И ЖИВЫ на lk.liderra.ru. + Сделано 19 из 21 задачи UX-аудита. Не сделаны: T16 различимые имена черновиков, + T21 шаг 3 живая оплата картой (внешняя блокировка — go-live ЮKassa не завершён). +СЛОЙ 3 — реклама «за показы» (CPM): ГОТОВА в ветке, НЕ влита в main, НЕ на проде, + рубильник Директа ВЫКЛ. 23 коммита. Тесты рекламного модуля 156/156. + +ЧТО ЗАКРЫТО В СЕССИИ 27.07 (коммит dd894a52): + Заморозка рекламного кошелька. Была дыра: freeze ставился один раз при запуске на + всю смету, а release не вызывался НИГДЕ. Главное следствие было блокирующим — + charge уменьшал balance_rub, но не трогал frozen_rub, одни и те же рубли считались + дважды, свободный остаток (balance − frozen) уходил в минус, а isSolvent проверяется + сразу после списания в ChargeCampaignSpendJob → клиент объявлялся неплатёжеспособным + после ПЕРВОГО суточного списания и AdStopAll глушил все кампании. Кампания умерла бы + после первого дня показов при полном кошельке. + Исправлено: заморозка тает вместе со списанием + снимается на всех 4 выходах — + completed (CampaignImpressionCharger), rejected (SyncCampaignModerationJob), + stopped_no_funds (PauseCampaignsOnAdStop), paused (контроллер), а resume морозит + неоткрученный остаток сметы ДО обращения к Директу и отдаёт 409 при нехватке денег. + Решение владельца по паузе: на паузе деньги свободны, при возобновлении морозим снова. + Удаление кампании выходом НЕ является — destroy разрешён только для черновика. + +════════ ЧТО ОСТАЛОСЬ (по приоритету) ════════ +🔴 БЛОКИРУЮЩЕЕ (без этого реклама не работает): + 1. Рубильник Директа. В боевом .env есть YANDEX_DIRECT_TOKEN, но НЕТ + YANDEX_DIRECT_ENABLED (default false) и НЕТ YANDEX_DIRECT_BASE_URL + (default = ПЕСОЧНИЦА api-sandbox.direct.yandex.com). Доступ к API одобрен 26.07. + Переключить на https://api.direct.yandex.com + ENABLED=true, затем + php artisan config:cache ПОД www-data (квирк #107 — иначе портал ляжет). + 2. Задача 9 — контролируемый первый живой запуск: одна кампания, крошечный сегмент, + минимальный SpendLimit; 200 OK на каждом методе; тип CPM_BANNER_CAMPAIGN; id + проставлены; НЕМЕДЛЕННО suspendCampaign до начала открутки. Сверить реальные имена + enum/полей с контрактом (файл п.4) и поправить код при расхождении. + ⚠️ ТОЛЬКО по отдельному «go» владельца. + 3. Отчёт показов getCampaignImpressions вживую: формат TSV vs async/поллинг; что + уникальный ReportName даёт СВЕЖИЕ данные, а не кэш; что тело — одно число показов. + От этого зависит корректность списаний. + +🟠 КРУПНОЕ: + 4. Влить показы в main и выкатить по ранбуку + docs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md. + ⚠️ При выкате: на боевом ad_settings сейчас колонка markup_percent = 30.00, а ветка + использует ad_margin_percent = 40% — проверить миграцию наценки, это ДЕНЬГИ. + ⚠️ При новых RLS-таблицах ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе + джобы и админ-экран увидят тихий ноль (грабля srv_bypass). + 5. РОБОТ (Часть C) — робот-креативщик вместо ручного оформления макета в конструкторе + Яндекса, по образцу docs/superpowers/specs/2026-07-26-mts-telegram-ads-bot-design.md. + Сначала дизайн-спека → одобрение владельца → стройка. + 6. ВЫБОР (Часть A) — выбор клиенту в мастере: Яндекс сам нарежет 15 размеров из одной + картинки ИЛИ клиент грузит свои 15; плюс объяснение, что делает ИИ Яндекса. + +🟡 ХВОСТЫ СЛОЯ 2 (уже на бою): + 7. T16 — различимые имена черновиков (сейчас все «Реклама от 25.07.2026»). + 8. T21 шаг 3 — живая оплата картой, только с владельцем; без успешного платежа + «работает» НЕ заявлять. + 9. Админ-экран «Реклама — расход и маржа» живьём не пройден, нужен админ-доступ. +10. Pa11y по авторизованным рекламным экранам (сейчас конфиг бьёт только по публичным + localhost-страницам). + +⚪ УБОРКА: +11. AdvertisingCampaignEndpointTest идёт БЕЗ изоляции (нет RefreshDatabase/ + DatabaseTransactions) и копит строки в liderra_testing → при полном прогоне ложно + краснеют соседи. Изолировать. +12. Larastan в этом worktree не запускается (junction-vendor рушит bootstrap) — гонять + composer stan на обычном чекауте перед вливанием. + +════════ ГРАБЛИ ОКРУЖЕНИЯ ════════ +• Перед прогоном тестов: DB_DATABASE=liderra_testing php artisan migrate:fresh --force +• Прогон: vendor/bin/pest tests/Feature/Advertising (156/156 зелёные на 27.07). + Полный vendor/bin/pest — 3570 тестов, 3548 проходят; 22 падения НАКОПИТЕЛЬНЫЕ + (изолированно на чистой БД все зелёные), к рекламе отношения не имеют. +• Коммит: LEFTHOOK_EXCLUDE=larastan, сообщение БЕЗ круглых скобок, по эскейпу владельца, + явными путями (каталог /моя/ в .gitignore — git add пропускает молча). +• В worktree «Can't find lefthook in PATH» — безобидно. +• 🪤 В рабочей папке лежит ЧУЖАЯ незакоммиченная правка + app/tests/Feature/Advertising/CampaignImpressionChargerTest.php — НЕ коммитить как своё. + +════════ С ЧЕГО НАЧАТЬ ════════ +Прочитать п.1–4, показать владельцу короткий статус простым языком и спросить, что +берём: go-live Директа (пп.1–3, нужен его «go»), выкат показов (п.4), РОБОТ (п.5), +ВЫБОР (п.6) или хвосты. Ничего не выкатывать и рубильники не включать без разрешения. +``` diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v10.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v10.md new file mode 100644 index 00000000..f885ac33 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v10.md @@ -0,0 +1,258 @@ +# Промт перезапуска — робот-грузчик креативов, v10 от 27.07.2026 + +Продолжаем рекламу Лидерры «за показы» в Яндексе. **Фазы 1 и 2 сделаны и закоммичены, +задача 11 фазы 3 закрыта — живая разметка кабинета снята.** Твоя работа начинается +с задачи 12, и владелец рядом для неё **не нужен**. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Сверь: `git log --oneline -4`. Верхний коммит — `5b8813b0` разметка кабинета, +под ним `9234a9c2`, `af53dfed`, `c9406533`. Никуда не пушили. + +## 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** Решено им + 27.07.2026. Разметка уже снята, лезть туда снова до задачи 14 незачем. +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, account suspended, 403. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца**, сообщение **без круглых скобок**, + `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- Рядом лежат три устаревших незакоммиченных промта — `...chast4-v3.md`, `...chast4-v4.md`, + `...robot-kreativov-v8.md`, и предыдущий `...robot-kreativov-v9.md` в коммите. + **Не читай их**, не коммить. Удалять — только с разрешения владельца. +- `app/app/Listeners/PauseCampaignsOnAdStop.php` git помечает изменённым, но содержимое + **побайтово совпадает** с коммитом. Причуда Windows, не трогать. +- Владелец — не программист. Говорить простым русским, без программистских слов. + +## Как мы работаем + +1. **Строим субагентами.** На каждую задачу — свежий субагент, полный текст задачи в промте. + Субагент план **не читает**, текст даёшь ты. +2. **Субагентам коммитить запрещено.** Ни `commit`, ни `add`, ни `stash`, ни смена ветки. +3. **До и после каждого субагента** — git-протокол Pravila §15.1: запомнить ветку и + `git rev-parse HEAD`, после возврата сверить. Разошлось — стоп. +4. **Денежный диф читать глазами самому.** Выходов снятия заморозки денег ровно **четыре** + — `->release(` у `AdWalletService`; ещё три `$lock->release()` это замки Redis, не деньги. + Пятого не создавать. Проверка: `grep -rn "\->release(" app/app --include=*.php`. +5. **Отчёты проверяющих агентов тоже проверять** — находка агента это гипотеза, а не факт. +6. 🔴 **План сверять с живым кодом перед каждой задачей** — см. следующий раздел. +7. **Хуки в worktree немые**, «Can't find lefthook in PATH». Gitleaks гонять вручную из корня + основного репо: `./bin/gitleaks.exe detect --source .claude/worktrees/reklama-pokazy --log-opts " -1"`. +8. **За собой убирать.** Временные файлы не складывать в корень репозитория; служебные файлы + браузера, насыпанные своей сессией в `.playwright-mcp/`, удалять — чужие не трогать. + +## 🔴 Урок фазы 2: четыре мины в плане, которые тесты не ловят + +Все четыре — один класс: **на тестах невидимо, на бою ломается**. Тесты ходят в базу +суперпользователем и с отключёнными защитами, а бой — ограниченными ролями. + +1. **Не та роль базы.** План выдал права роли `crm_supplier_worker`, а канал робота ходит + через посредник `admin-db` → `crm_admin_user`. На бою — отказ доступа. +2. **Забытая защита от подделки запросов.** Канал `/api/creative-robot/*` не был в исключениях + CSRF — на бою отчёт робота отбивался бы ошибкой 419, а в тестах проверка отключена движком. +3. **Выдача чужих картинок.** План отдавал файл баннера просто по номеру — утёкший токен + позволил бы перебором вычерпать картинки всех клиентов. +4. **Обход рубильника.** Постановка задания ходит в API Яндекса за слепком, а план ставил её + ДО проверки рубильника — портал полез бы в живой Яндекс при выключенном рубильнике. + +**Вывод:** перед каждой задачей открывай реальные файлы, которые план трогает. Особенно +проверяй роль БД, посредники на маршруте и поведение при выключенном рубильнике. + +## Что уже сделано — не переписывать + +### Фаза 1, коммиты `c9406533` и `af53dfed` + +Ядро переведено на набор креативов: у каждого баннера свой номер креатива и своё объявление; +кап веса баннера 512 000 байт; `YandexDirectClient::listImageCreativeIds()` даёт слепок +креативов аккаунта; `CreativeIdMatcher` опознаёт новые креативы по разнице слепков и размеру; +запуск **возобновляемый**; **замок на повторный запуск** только из `draft`/`queued`; +**замок на правку параметров** после заведения кампании в Яндексе, 409 на всё кроме названия; +модерация считается по баннерам. Права: `crm_admin_user` на `ad_campaign_banners`, нумераторы +семи рекламных таблиц. Журнал схемы — v9.03, v9.04, v9.05. + +### Фаза 2, коммит `9234a9c2` + +- **Таблица `ad_creative_jobs`** — очередь заданий. RLS `tenant_isolation` + `FORCE`; гранты: + `crm_app_user` чтение-вставка-правка и нумератор, `crm_admin_user` только чтение-правка. + Журнал схемы — v9.06. Прошла `rls-reviewer` без блокеров. +- **`CreativeJobService`** — `enqueue` / `takeNext` / `complete` / `fail`. Задания выдаются + строго по одному: два параллельных перемешают слепки. При несовпадении слепка не трогается + ни один баннер, задание уходит в сбой с понятной причиной. +- **Служебный канал робота** `/api/creative-robot/*` — токен в заголовке + `X-Creative-Robot-Token`, посредник `CreativeRobotToken`, настройка + `services.creative_robot.token` из `CREATIVE_ROBOT_TOKEN`, маршруты под + `['admin-db', 'creative-robot']`. Три адреса: взять задание, скачать файл баннера, + отчитаться. Запустить кампанию или потратить деньги через канал нельзя. +- **Запуск без картинок** не ошибка: портал ставит задание роботу и отвечает 202 + «Готовим картинки в рекламном кабинете», кампания остаётся черновиком, деньги не морозятся. +- **247 из 247 тестов рекламы зелёные** в личном прогоне контроллера. + +### Задача 11, коммит `5b8813b0` — разметка кабинета снята живьём + +Файл **`bots/yandex-creatives/docs/cabinet-flow.md`** — читать перед задачей 14 целиком. +Снято 27.07.2026 вместе с владельцем в кабинете `sasha261185`, кампания `713051718`, +группа `5776749172`. Ничего не сохранялось: до и после — те же 15 объявлений. + +Главное оттуда: + +- 🔑 **Системное окно «Выберите файл» роботу не нужно.** В форме есть + `input[type=file][data-testid="CreativeActionsMenu.FileInput"]`, + `accept="image/png,image/jpeg,image/jpg,image/gif"`, `multiple` — Playwright кладёт файлы + прямо в него через `setInputFiles`. +- **Весь путь до окна загрузки проходится программой** — проверено живым прогоном скрипта. +- Путь: открыть `.../dna/banners-edit?ulogin=<логин>&campaigns-ids=<кампания>&groups-ids=<группа>&is-new=1` + → `[data-testid="ImageCreativeEditor.OpenMenu"]` → `[data-testid="ImageCreativeEditor.Menu.add-html5"]` + → окно `[data-testid="CanvasModal.html5"]`. +- В окне: `[data-testid="CanvasFooter.SubmitButton"]` «Создать» — **серая, пока не выбраны + файлы**, снятие блокировки и есть признак «Яндекс файлы принял»; + `[data-testid="CanvasFooter.CancelButton"]`, `[data-testid="CanvasModal.html5.CloseButton"]`; + вкладки «Мои креативы»/«Загрузить»/«Архивные» — **без своих `data-testid`**, искать по роли + и имени, «Загрузить» выбрана по умолчанию. +- 🔴 `[data-testid="SaveBannerButton"]` «Сохранить изменения» — **единственная кнопка на пути, + которая меняет кабинет. Робот не нажимает её никогда.** Форма нового объявления — просто + «прихожая» к окну загрузки: залил файлы и ушёл, объявление не создаётся. +- Признак «вход жив»: `[data-testid="Sidebar.BalanceExpanded"]` на `/wizard/overview/`. + Признак «вход слетел»: редирект на `passport.yandex.ru` **или** блок баланса не появился за 15 с. +- Подтверждено живьём: Яндекс сам пишет в форме «Конструктор креативов закрылся 1 июня». +- Подтверждено: у всех 15 креативов имя «Новый креатив», потому что **поле имени одно на всю + загрузку** — владелец залил файлы пачкой. Опознание по слепкам и размеру остаётся + единственным надёжным способом. + +**Чего в разметке НЕТ и надо добрать в задаче 14** (файлов не грузили, чтобы ничего не +создавать): сколько ждать обработки после «Создать» и как выглядит ошибка «файл не подошёл». + +## 🔴 Тестовая база — отдельная под эту ветку + +```bash +cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising +``` + +Общую `liderra_testing` **не трогать**: 27.07.2026 за неё дралась параллельная сессия и стёрла +оттуда рекламные таблицы — журнал миграций считал их накаченными, а на деле их не было. +Если базы нет: + +```bash +createdb -U postgres -h 127.0.0.1 liderra_testing_reklama +cd app && DB_DATABASE=liderra_testing_reklama php artisan migrate --force +``` + +**Пока идут две сессии одновременно, зелёные тесты в общей базе ничего не значат.** + +## 🔴 Грабли, стоившие крови + +- **`composer pint` без списка путей проходится по всему складу** и затирает чужие + незакоммиченные правки. 27.07.2026 так была безвозвратно потеряна правка соседней сессии. + Только: `cd app && ./vendor/bin/pint <файл> <файл>`. +- **Многострочный текст в стиле PowerShell при запуске через bash не работает** — символы + `@'` и `'@` попадают внутрь сообщения коммита. Для bash — обычный heredoc. +- **`git status --cached` не существует** — смотреть staged через `git diff --cached --name-status`. +- **Edit может перевести файл в CRLF целиком** — тогда pint краснеет по всему файлу. +- **`markdownlint-cli2 --fix` чинит и чужие ошибки.** В `db/CHANGELOG_schema.md` есть + предсуществующая MD032 в записи v9.02 — не трогать. Гонять **без** `--fix`. +- **Larastan в worktree не запускается** — junction-vendor, пустой exit 1. Опираться на Pest. +- **`php artisan tinker` без `DB_DATABASE` уходит в dev-базу `liderra`.** +- Изредка краснеет `AdminAdvertisingSpendTest` в сквозном прогоне — плавает, чужой файл, + прогнать отдельно и **не чинить**. +- 🔴 **`db/03_service_bypass_policies.sql`** — после выката новых таблиц на бой **перезапустить**, + иначе служебные роли молча увидят ноль строк. +- Основная рабочая папка стоит на чужой ветке — **не трогать**. +- Квирк 107: `config:cache` на проде — **только от www-data**, не от root. + +## Что проверено на боевой базе 27.07.2026 — не перепроверять + +- У всех семи рекламных таблиц, живущих на бою, право на нумератор у `crm_app_user` **есть**. +- Таблиц `ad_campaign_banners` и `ad_creative_jobs` на бою **нет** — они только в этой ветке. +- Модуль прогрева: дырки в правах нет. + +## Открытое — решает владелец, сам не закрывай + +1. **Корень проблемы с правами.** В `db/02_grants.sql` строки `ALTER DEFAULT PRIVILEGES + IN SCHEMA public` написаны **без** `FOR ROLE crm_migrator`, а миграции идут под + `crm_migrator`. Гранты приходится дописывать руками в каждой миграции — уже дважды забывали. +2. **Соседние ручки не под замком** — `uploadBanner`, `deleteBanner`, `toggleBannerIncluded`, + `approveBanners` не смотрят на `yandex_campaign_id`. Намеренно: перезаливка набора — + открытый вопрос спеки §10. +3. **Имя креатива можно задавать своё.** У поля имени в окне загрузки есть карандашик + (`canvas-editable-label__btn_action_edit-name`). Если грузить по одному файлу и называть + каждый по-своему, опознание стало бы прямым, без слепков. **Ядро не трогаем** — слепки + написаны и покрыты тестами. Минусы перехода: грузить по одному дольше, и появляется + зависимость от того, что Яндекс не подменит наше имя. Записано в `cabinet-flow.md` §4. +4. **Потерянная чужая правка** в `CampaignImpressionChargerTest.php` — владелец знает. +5. **Три устаревших промта** v3/v4/v8 лежат незакоммиченными — удалять только с разрешения. + +## Что делать дальше — фаза 3, задачи 12–17 + +План: `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md`. +Спека: `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +Разметка кабинета: `bots/yandex-creatives/docs/cabinet-flow.md`. + +- **Задача 12** — скелет робота и конфиг. Кабинет не нужен. +- **Задача 13** — разговор с порталом: взять задание, скачать файлы, отчитаться. Кабинет не нужен. +- **Задача 14** — браузер, проверка входа, загрузка в кабинет. **Разметка уже есть** — писать + строго по `cabinet-flow.md`, селекторы не выдумывать. Первый настоящий прогон с файлами — + вместе с владельцем, там же добрать недостающее из §5 разметки. +- **Задачи 15–17** — сторож и письма, установка на боевом сервере, живая сверка. + +Обязательные проверки: `rls-reviewer` на каждую миграцию; `prod-deploy-validator` перед +runbook установки в задаче 16; `superpowers:requesting-code-review` на всю ветку перед живой +сверкой в задаче 17. TDD по-настоящему: сначала падающий тест, увидеть падение своими глазами. + +## Суть проекта в двух абзацах + +Портал заводит медийную кампанию «за показы» в Директе через API целиком, кроме одного шага: +**картиночный креатив через API создать нельзя** — `creatives.add` принимает только видео. +Конструктор креативов Яндекса **закрыт 01.06.2026**, показы его креативов остановлены с 30.06 — +проверено живьём дважды, в том числе надписью в самом кабинете. Медийная кампания устроена как +**объявление на каждый размер блока** со своим креативом: в живой кампании прогрева +`№713051718` их 15. + +Поэтому **робот** относит готовые файлы клиента в веб-кабинет через «Загрузить креативы», а +**ядро** уже переведено на «креатив у каждого баннера». Номера креативов портал добывает сам — +слепком `creatives.get` до и после загрузки, сопоставляя по ширине и высоте. Робот номера не +читает, кампаний не создаёт, показы не запускает, к деньгам не прикасается. + +## Решения владельца — не пересматривать + +| Вопрос | Решение | +|---|---| +| Границы робота | Только грузит файлы и говорит «готово». Всё остальное — портал через API | +| Как получает работу | Сам опрашивает портал, сам отчитывается. Без человека | +| Кто готовит файлы | **Только клиент грузит готовые.** Портал ничего не режет | +| Набор размеров | Целевой — 15; клиент грузит сколько есть, минимум один | +| Где живёт робот | **На боевом сервере**, рядом с Лидеррой | +| Вход в кабинет | Экран на боевом плюс удалённый рабочий стол, слушают только `127.0.0.1` | +| Объём | Робот и переделка ядра — одной работой | +| Живой кабинет | **Заходить только вместе с владельцем** — решено 27.07.2026 | + +## Проверенные факты — на них опирается весь код + +- `creatives.get` работает, отдаёт `Id`, `Type`, `Name`, `Width`, `Height`, `PreviewUrl`. +- Загруженные JPG Яндекс хранит типом **`HTML5_CREATIVE`**, не «image». Отбирать по нему. +- **Имена для опознания не годятся** — всем загруженным Яндекс ставит «Новый креатив». +- `getAdsModeration()` отдаёт статусы passthrough — `ACCEPTED`/`REJECTED`/`MODERATION`. +- Боевой сервер: 12 ядер, 36 ГБ памяти, 66 ГБ диска, Node 20 стоит, браузера нет. +- Размеры 15 объявлений живой кампании: 336×280, 480×320, 160×600, 320×50, 320×100, 300×250, + 320×480, 300×500, 970×250, 1000×120, 240×600, 240×400, 728×90, 300×300, 300×600. + +## Чего НЕ делать + +- Не выдумывать селекторы кабинета — они сняты, лежат в `cabinet-flow.md`. +- Не обходить намеренную заглушку в `cabinet.js` до сверки с `cabinet-flow.md`. +- Не переезжать на «перформанс»-кампанию ради авто-подгонки из одной картинки — это отказ от + модели «за показы». +- Не удалять `CampaignBannerService` / `BannerGenerator` — отдельное решение владельца. +- Не включать рубильник Директа самостоятельно. +- Не создавать пятый выход снятия заморозки денег. +- Не переписывать возобновляемый запуск, два замка и очередь робота — закрыты и покрыты тестами. + +## Открытое по продукту, выяснить по ходу + +Мусор в кабинете при перезаливке набора · лимиты Яндекса на число креативов и темп загрузки · +сколько живёт вход в кабинет без действий · как выглядит ошибка «файл не подошёл». + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v11.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v11.md new file mode 100644 index 00000000..844451b2 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v11.md @@ -0,0 +1,328 @@ +# Промт перезапуска — робот-грузчик креативов, v11 от 27.07.2026 + +Продолжаем рекламу Лидерры «за показы» в Яндексе. **Фазы 1 и 2 сделаны и закоммичены. +Фаза 3 сделана на две трети: задачи 11–15 закрыты, робот написан целиком и работает на +тестах.** Остались задачи 16 и 17 — обе трогают боевой сервер, поэтому **начинать их без +слова владельца нельзя**. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Первым делом сверь: `git log --oneline -6`. Должно быть так: + +``` +20d9dc6d feat робот креативов: цикл работы, сторож, письма и описание для человека +812fed77 feat робот креативов: браузер, проверка входа и загрузка креативов по живой разметке кабинета +602b8f5c feat робот креативов: разговор с порталом — взять задание, скачать файлы, отчитаться +c548fb73 feat робот креативов: скелет проекта и конфиг +6723d14b docs робот креативов: промт перезапуска v10 — задача 11 закрыта, дальше задача 12 +5b8813b0 docs робот креативов: разметка экранов загрузки в кабинете Директа +``` + +Никуда не пушили. Все коммиты проверены gitleaks — утечек нет. + +--- + +## 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** Разметка уже + снята, лезть туда до задачи 17 незачем. +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, 403 account suspended. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца** через AskUserQuestion, где метка опции = точная + git-команда. Сообщение **без круглых скобок**, `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- Три устаревших незакоммиченных промта `…chast4-v3.md`, `…chast4-v4.md`, + `…robot-kreativov-v8.md` — **не читай, не коммить**, удалять только с разрешения. +- `app/app/Listeners/PauseCampaignsOnAdStop.php` git помечает изменённым, но содержимое + **побайтово совпадает** с коммитом. Причуда Windows, не трогать. +- Владелец — не программист. Говори простым русским, без программистских слов. + +--- + +## Как мы работаем + +1. **Строим субагентами**, полный текст задачи — в промте субагента. Субагент план не читает. +2. **Субагентам коммитить запрещено.** До и после каждого субагента сверяй + `git branch --show-current` и `git rev-parse HEAD`. +3. **Отчёты проверяющих агентов проверяй сам.** Находка агента — гипотеза, а не факт. В этой + сессии проверяющий нашёл настоящую дыру и одновременно приписал коду то, чего в нём не было. +4. **Денежный диф читать глазами.** Выходов снятия заморозки денег ровно **четыре** + (`->release(` у `AdWalletService`). Пятого не создавать. +5. 🔴 **План сверять с живым кодом ПЕРЕД каждой задачей.** План писался до того, как была + снята разметка кабинета и дописан портал. За задачи 12–15 в плане нашлось **четыре** + расхождения, и каждое ломало бы робота на бою молча — см. раздел «Уроки». +6. **Хуки в worktree немые.** gitleaks гонять вручную: + `./bin/gitleaks.exe detect --source .claude/worktrees/reklama-pokazy --log-opts " -1"`, + а по незакоммиченному — `--no-git --source <папка>`. +7. **Тест на защиту проверять вырезанием защиты.** Сломай код нарочно, убедись, что тест + покраснел, верни обратно. В этой сессии так поймали тест-пустышку. +8. **За собой убирать.** Временные файлы не в корень репозитория; свои файлы из + `.playwright-mcp/` удалять, чужие не трогать. + +--- + +## 🔴 Уроки: мины, которые тесты не ловят + +Один класс: **на тестах невидимо, на бою ломается**. + +**Фаза 2 — четыре мины:** + +1. **Не та роль базы** — права выдали `crm_supplier_worker`, а канал робота идёт через + посредник `admin-db` → роль `crm_admin_user`. +2. **Забытое исключение CSRF** для `/api/creative-robot/*` — на бою 419, в тестах проверка + отключена движком. +3. **Выдача чужих картинок** — файл отдавался просто по номеру, перебором вычерпывались бы + картинки всех клиентов. +4. **Обход рубильника** — постановка задания ходит в API Яндекса, а стояла ДО проверки рубильника. + +**Задачи 12–15 — ещё четыре, все из-за расхождения плана с реальностью:** + +1. **В плане была заглушка вместо загрузки** — он писался до того, как сняли разметку. + Заглушку заменили настоящим кодом по `cabinet-flow.md`. +2. **Кампании в Яндексе ещё нет, когда робот работает.** Портал ставит задание **до** + создания кампании (`AdvertisingCampaignController::launch`, ветка `creatives_pending` → 202), + потому что кампания заводится сразу с готовыми номерами креативов + (`CampaignLauncher` строки ~167–177). Значит открыть «свою» группу робот не может. + Решение — **«прихожая»** (см. ниже). +3. **В кабинет уходили описания баннеров вместо путей к файлам** — на бою не загрузилось бы + ничего. Теперь есть тест «в кабинет уходят ПУТИ к файлам». +4. **Папки для картинок считались от каталога запуска** — робота запускает расписание, каталог + у него любой. Теперь считаются от корня робота. + +**И одна моя собственная, найденная проверяющим:** сперва код объявлял провалом ситуацию +«окно не закрылось после Создать». Это враньё — окно может остаться открытым и после +**удачной** загрузки. Робот соврал бы «сбой», портал похоронил бы задание, а креативы уже +лежали бы в кабинете. Исправлено: правду об успехе знает **портал** по слепку `creatives.get`, +робот только докладывает, что видел. + +--- + +## Что уже сделано — не переписывать + +### Фаза 1 — ядро на наборе креативов (`c9406533`, `af53dfed`) + +Свой креатив и своё объявление у каждого баннера, кап 512 000 байт, `listImageCreativeIds()`, +`CreativeIdMatcher`, возобновляемый запуск, замок на повторный запуск и на правку параметров, +модерация по баннерам. Схема БД — v9.03–v9.05. + +### Фаза 2 — очередь заданий и канал робота (`9234a9c2`) + +Таблица `ad_creative_jobs` с RLS и грантами (схема v9.06, `rls-reviewer` без блокеров); +`CreativeJobService` — задания строго по одному, при несовпадении слепка не трогается ни один +баннер; канал `/api/creative-robot/*` с токеном `X-Creative-Robot-Token`; запуск без картинок +отвечает 202 и ставит задание, деньги не морозятся. **247/247 тестов зелёные.** + +Сторона портала проверена и **менять её не надо**. Она отвечает так: + +- `GET /api/creative-robot/next` → `{"job": null}` либо + `{"job":{"id":7,"campaign_id":42,"banners":[{"banner_id":1,"width":300,"height":250,"file_url":"…"}]}}` +- `GET /api/creative-robot/banners/{id}/file` → сам файл, только для задания «в работе» +- `POST /api/creative-robot/jobs/{id}/done` с телом `{"ok":true}` либо `{"ok":false,"reason":"…"}` +- Все три требуют заголовок `X-Creative-Robot-Token`. + +### Задача 11 — живая разметка кабинета (`5b8813b0`) + +**`bots/yandex-creatives/docs/cabinet-flow.md`** — читать целиком перед задачей 17. Снято +в кабинете `sasha261185`, кампания `713051718`, группа `5776749172`; ничего не сохранялось, +до и после — те же 15 объявлений. + +- 🔑 **Системное окно выбора файла роботу не нужно**: есть настоящее поле + `input[type=file][data-testid="CreativeActionsMenu.FileInput"]`, `multiple`. +- Путь: `.../dna/banners-edit?…&is-new=1` → `ImageCreativeEditor.OpenMenu` → + `ImageCreativeEditor.Menu.add-html5` → окно `CanvasModal.html5`. +- `CanvasFooter.SubmitButton` «Создать» — **серая, пока нет файлов**; снятие блокировки = + «кабинет файлы принял». +- 🔴 `SaveBannerButton` — **единственная кнопка, которая меняет кабинет. Робот не жмёт её никогда.** +- Вход жив: `Sidebar.BalanceExpanded` на `/wizard/overview/`. Вход слетел: редирект на + `passport.yandex.ru` **или** нет признака за 15 с — проверяются оба условия. + +### Задачи 12–15 — сам робот (`c548fb73`, `602b8f5c`, `812fed77`, `20d9dc6d`) + +Живёт в `bots/yandex-creatives/`. **35 тестов из 35 зелёные.** + +| Файл | За что отвечает | +|---|---| +| `src/config.js` | настройки из окружения; падает с понятным сообщением, если чего-то нет | +| `src/portal.js` | разговор с порталом: взять задание, скачать файлы, отчитаться | +| `src/session.js` | жив ли вход в кабинет; оба признака «слетел» | +| `src/cabinet.js` | загрузка креативов строго по разметке | +| `src/browser.js` | постоянный профиль браузера (единственный файл, который тянет Playwright) | +| `src/human.js` | человекоподобная пауза; отдельно от браузера нарочно, чтобы проверки шли без Playwright | +| `src/runner.js` | один проход целиком + сторож | +| `src/mailer.js` | письма «алярм» и «готово» | +| `bin/login.js` | разовый вход глазами | +| `bin/keepalive.js` | тихий заход раз в ~15 минут | +| `bin/run.js` | один проход по расписанию | +| `README.md` | человеческим языком: что делает, чего не делает, что делать по каждому письму | + +**Решения, принятые в этих задачах — не пересматривать без причины:** + +1. **«Прихожая»** — `YC_ANTEROOM_CAMPAIGN_ID` + `YC_ANTEROOM_GROUP_ID`: существующие кампания + и группа, форму **нового** объявления которых робот открывает только чтобы дойти до окна + загрузки, и уходит не сохраняя. Своей кампании у него в этот момент нет (см. Уроки, мина 2). +2. **Приговор об успехе выносит портал, не робот.** «Окно не закрылось» провалом не считается: + робот закрывает окно крестиком, возвращает `{modalClosed, cabinetSaid}`, сторож шлёт письмо, + а порталу докладывает «готово» — тот сверит слепок и сам пометит сбой, если креативов нет. +3. **При сбое отчёт порталу идёт ПЕРВЫМ, письмо вторым** — упавшая почта не должна подвешивать + задание «в работе» и заклинивать очередь. +4. **Картинки клиента стираются при любом исходе** — чужие материалы копиться на сервере не должны. +5. **Логин кабинета** — `YC_CABINET_LOGIN`, идёт в адресах Директа параметром `ulogin`. + +**Тесты-сторожа, которые нельзя ломать** (оба проверены вырезанием защиты): + +- «НИКОГДА не нажимает Сохранить изменения» — вернёшь клик, тест краснеет. +- «ждёт, пока кнопка Создать разблокируется» — выкинешь ожидание, краснеют два теста. + +--- + +## Как запускать проверки + +```bash +cd bots/yandex-creatives && node --test # 35 тестов, браузер не нужен +``` + +🪤 **`npm install` локально НЕ делали** — он тянет браузеры Playwright (~1 ГБ). Поэтому +`src/browser.js` локально не импортируется, его проверяли только `node --check`. Ставить +зависимости — на боевом сервере, в задаче 16. + +Портал (PHP) — отдельная тестовая база: + +```bash +cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising +``` + +Общую `liderra_testing` **не трогать** — за неё дралась параллельная сессия и стёрла +рекламные таблицы. Если базы нет: `createdb -U postgres -h 127.0.0.1 liderra_testing_reklama`, +затем `migrate --force` с тем же `DB_DATABASE`. + +--- + +## 🔴 Грабли окружения + +- **`composer pint` без списка путей затирает чужие незакоммиченные правки** — одна уже + потеряна безвозвратно. Только `cd app && ./vendor/bin/pint <файл>`. +- **PowerShell-строки в bash не работают** — `@'` и `'@` попадают в текст коммита. Только heredoc. +- **`.git` в worktree — это ФАЙЛ, а не папка.** Путь `.git/COMMIT_MSG` не существует; текст + коммита класть в `$(git rev-parse --git-dir)/COMMIT_MSG…`. +- **`git status --cached` не существует** — смотреть `git diff --cached --name-status`. +- **Новые файлы `git commit <пути>` не берёт** — untracked нужно сначала `git add`. +- **Bash-инструмент помнит текущий каталог между вызовами** — после `cd bots/yandex-creatives` + следующая команда стартует уже оттуда. Проверяй `pwd`. +- **`markdownlint-cli2 --fix` чинит и чужие ошибки** — в `db/CHANGELOG_schema.md` есть + предсуществующая MD032. Гонять **без** `--fix`. +- **Larastan в worktree не запускается**, lefthook в PATH нет — опираться на Pest и `node --test`. +- **`tinker` без `DB_DATABASE` уходит в dev-базу `liderra`.** +- `AdminAdvertisingSpendTest` плавает в сквозном прогоне — чужой файл, **не чинить**. +- 🔴 **`db/03_service_bypass_policies.sql`** — после выката новых таблиц на бой **перезапустить**, + иначе канал робота увидит ноль строк молча. +- Квирк 107: `config:cache` на проде — **только от www-data**. + +--- + +## Проверено на боевой базе 27.07 — не перепроверять + +Право на нумератор у `crm_app_user` есть у всех семи рекламных таблиц; `ad_campaign_banners` +и `ad_creative_jobs` на бою **нет**; в модуле прогрева дырки в правах нет. + +--- + +## Открытое — решает владелец + +1. 🔴 **Какую кампанию с группой назначить «прихожей»?** Моя рекомендация — завести отдельную + пустую **остановленную** кампанию, чтобы робот вообще не подходил к работающей рекламе. + Заводить её в боевом кабинете — решение владельца. Нужно к задаче 16. +2. **Корень проблемы с правами** — `ALTER DEFAULT PRIVILEGES` в `db/02_grants.sql` без + `FOR ROLE crm_migrator`. +3. **Соседние ручки баннеров не под замком** — завязано на открытый вопрос спеки §10. +4. **Имя креатива можно задавать своё** (карандашик у поля имени) — опознание стало бы прямым, + но грузить пришлось бы по одному файлу и появилась бы зависимость от Яндекса. **Ядро не трогаем.** +5. **Потерянная чужая правка** в `CampaignImpressionChargerTest.php`. +6. **Три устаревших промта** — удалять только с разрешения. + +--- + +## Дальше — задачи 16 и 17 + +План: `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md`. +Спека: `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +Разметка: `bots/yandex-creatives/docs/cabinet-flow.md`. + +**Задача 16 — установка на боевом сервере.** Только с «go». Обязательно сначала +`prod-deploy-validator` (вердикт GO/NO-GO). Поставить робота, `npm install` + браузеры, +заполнить `.env` (включая «прихожую»), завести вход глазами через удалённый рабочий стол +внутри виртуального экрана, повесить расписание на `run:once` и `keepalive`. Наружу порт +не открывать. + +**Задача 17 — живая сверка.** Только вместе с владельцем. Перед ней — +`superpowers:requesting-code-review` на всю ветку. Первый настоящий прогон с файлами. + +🔑 **На этом прогоне добираем то, чего мы ещё ни разу не видели живьём** (в разметке честно +помечено как неизмеренное): + +- сколько ждать после нажатия «Создать»; +- как выглядит отказ «файл не подошёл» — робот нарочно отдаёт наружу текст самого кабинета, + чтобы он попал в письмо, вместо выдуманной формулировки. + +Обязательно: `rls-reviewer` на каждую миграцию, TDD по-настоящему. + +--- + +## Суть проекта — чтобы не терять смысл + +Портал заводит медийную кампанию «за показы» через API целиком, кроме одного шага: +**картиночный креатив через API создать нельзя** — `creatives.add` принимает только видео, +конструктор Яндекса закрыт 01.06.2026 (Яндекс сам пишет об этом в форме). Медийная кампания = +**объявление на каждый размер** со своим креативом; в живой кампании `713051718` их 15. + +Поэтому **робот** относит готовые файлы клиента в веб-кабинет, а **ядро** работает с моделью +«креатив у каждого баннера». Номера креативов портал добывает сам — слепком `creatives.get` +до и после. Робот номера не читает, кампаний не создаёт, показы не запускает, к деньгам +не прикасается. + +--- + +## Решения владельца — не пересматривать + +| Вопрос | Решение | +|---|---| +| Границы робота | Только грузит файлы и говорит «готово» | +| Как получает работу | Сам опрашивает портал, сам отчитывается | +| Кто готовит файлы | **Только клиент грузит готовые** | +| Набор размеров | Целевой 15; минимум один | +| Где живёт робот | **На боевом сервере** | +| Вход в кабинет | Экран на боевом + удалённый рабочий стол, только `127.0.0.1` | +| Живой кабинет | **Только вместе с владельцем** | + +--- + +## Проверенные факты + +`creatives.get` отдаёт `Id`, `Type`, `Name`, `Width`, `Height`, `PreviewUrl` · загруженные JPG +хранятся типом **`HTML5_CREATIVE`** · **имена для опознания не годятся**: поле имени одно на всю +загрузку, у всех 15 «Новый креатив» · `getAdsModeration()` даёт `ACCEPTED`/`REJECTED`/`MODERATION` · +боевой сервер: 12 ядер, 36 ГБ, Node 20, браузера нет · размеры 15 объявлений: 336×280, 480×320, +160×600, 320×50, 320×100, 300×250, 320×480, 300×500, 970×250, 1000×120, 240×600, 240×400, +728×90, 300×300, 300×600. + +--- + +## Чего НЕ делать + +Не выдумывать селекторы — они сняты живьём · не переезжать на «перформанс»-кампанию · +не удалять `CampaignBannerService` / `BannerGenerator` · не включать рубильник Директа · +не создавать пятый выход снятия заморозки денег · не переписывать возобновляемый запуск, +замки и очередь робота · не заставлять робота выносить приговор об успехе — это дело портала · +не давать роботу нажимать `SaveBannerButton`. + +--- + +## Открытое по продукту + +Мусор в кабинете при перезаливке набора · лимиты Яндекса на число креативов и темп загрузки · +сколько живёт вход в кабинет без действий · как выглядит ошибка «файл не подошёл». + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md new file mode 100644 index 00000000..115db56b --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md @@ -0,0 +1,661 @@ +# Промт перезапуска v12 — реклама «за показы», починка после полного разбора ветки + +**Дата:** 27.07.2026. **Заменяет** промт v11 (`2026-07-27-PROMPT-restart-robot-kreativov-v11.md`). + +Продолжаем рекламу Лидерры «за показы» в Яндексе. + +**Фазы 1 и 2 сделаны и закоммичены. Фаза 3 сделана на две трети: задачи 11–15 закрыты, +робот написан целиком и работает на тестах.** Сверх плана проведён **полный разбор всей +ветки четырьмя проверяющими**, и он нашёл **8 дыр, каждая из которых стреляет только +на боевом**. Твоя работа в этой сессии — **починить их по списку ниже**. Владелец рядом +для этого не нужен, кроме момента коммита. + +Задачи 16 и 17 (боевой сервер) — **после** починки и только со словом владельца. + +--- + +## 1. Где ты и что сверить первым делом + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Laravel-приложение — в подпапке `app/`. Робот — в `bots/yandex-creatives/`. + +Сверь `git log --oneline -7`. Должно быть так: + +```text +b66009bd docs робот креативов: промт перезапуска v11 — задачи 12-15 закрыты, дальше боевой сервер +20d9dc6d feat робот креативов: цикл работы, сторож, письма и описание для человека +812fed77 feat робот креативов: браузер, проверка входа и загрузка креативов по живой разметке кабинета +602b8f5c feat робот креативов: разговор с порталом — взять задание, скачать файлы, отчитаться +c548fb73 feat робот креативов: скелет проекта и конфиг +6723d14b docs робот креативов: промт перезапуска v10 — задача 11 закрыта, дальше задача 12 +5b8813b0 docs робот креативов: разметка экранов загрузки в кабинете Директа +``` + +Никуда не пушили. Все коммиты проверены gitleaks — утечек нет. + +Зелёная отметка на 27.07.2026, проверено в одиночку (не параллельно!): + +- портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + → **247/247, 892 проверки, ~41 с**; +- робот: `cd bots/yandex-creatives && node --test` → **35/35**. + +Незакоммичено только три устаревших промта (`chast4-v3`, `chast4-v4`, `robot-kreativov-v8`) — +**не читай, не коммить, удалять только с разрешения владельца**. + +--- + +## 2. 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, 403. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца** через AskUserQuestion, метка опции = точная git-команда. + Сообщение **без круглых скобок**, `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- `app/app/Listeners/PauseCampaignsOnAdStop.php` git иногда помечает изменённым, а содержимое + побайтово совпадает с коммитом. Причуда Windows, не трогать. +- Владелец — **не программист**. Отчитываться простым русским, без программистских слов. + +--- + +## 3. Как мы работаем + +1. **Строим субагентами**, полный текст задачи в промте — субагент план не читает. +2. **Субагентам коммитить запрещено.** До и после каждого — сверка ветки и `git rev-parse HEAD`. +3. **Отчёты проверяющих проверяй сам.** В прошлой сессии проверяющий нашёл настоящую дыру + и одновременно приписал коду то, чего в нём не было. +4. **Денежный диф читать глазами.** Выходов снятия заморозки денег ровно **четыре** (таблица + в §5). Пятого не создавать. +5. 🔴 **План сверять с живым кодом ПЕРЕД каждой задачей.** +6. **Хуки в worktree немые** — gitleaks вручную: `--log-opts " -1"`, по незакоммиченному + `--no-git --source <папка>`. +7. **Тест на защиту проверять вырезанием защиты**: временно сломай код, убедись, что тест + краснеет, верни файл (`git checkout -- <файл>`), в конце `git status --porcelain` пуст. +8. 🪤 **Не запускать несколько субагентов, которые одновременно гоняют тесты и ломают файлы.** + В прошлой сессии четыре проверяющих работали параллельно по одной тестовой базе + `liderra_testing_reklama` и по одним файлам — получили ложные красные тесты и ложную + тревогу «параллельная сессия правит worktree». Тесты гонять **по одному**. +9. **За собой убирать.** + +--- + +## 4. Что уже сделано — не переписывать + +**Фаза 1** (`c9406533`, `af53dfed`): ядро на наборе креативов, кап 512 000 байт, +`listImageCreativeIds()`, `CreativeIdMatcher`, возобновляемый запуск, замки, модерация +по баннерам. Схема v9.03–v9.05. + +**Фаза 2** (`9234a9c2`): таблица `ad_creative_jobs` с RLS (v9.06), `CreativeJobService`, +канал `/api/creative-robot/*` с токеном. Отвечает так: + +- `GET /api/creative-robot/next` → `{"job":null}` либо + `{"job":{"id":7,"campaign_id":42,"banners":[{"banner_id":1,"width":300,"height":250,"file_url":"…"}]}}` +- `GET /api/creative-robot/banners/{id}/file` → файл, только для задания «в работе» +- `POST /api/creative-robot/jobs/{id}/done` с `{"ok":true}` либо `{"ok":false,"reason":"…"}` +- Все три требуют заголовок `X-Creative-Robot-Token`. + +**Задача 11** (`5b8813b0`): `bots/yandex-creatives/docs/cabinet-flow.md` — живая разметка +кабинета. Читать целиком перед задачей 17. Ключевое: поле файлов настоящее +(`CreativeActionsMenu.FileInput`, `multiple`), системное окно не нужно; путь +`banners-edit?…&is-new=1` → `ImageCreativeEditor.OpenMenu` → `…Menu.add-html5` → +`CanvasModal.html5`; «Создать» серая, пока нет файлов; 🔴 `SaveBannerButton` — единственная +кнопка, меняющая кабинет, робот не жмёт её никогда; вход жив = `Sidebar.BalanceExpanded`, +слетел = редирект на паспорт **или** нет признака за 15 с. + +**Задачи 12–15** (`c548fb73`, `602b8f5c`, `812fed77`, `20d9dc6d`) — робот в +`bots/yandex-creatives/`: + +| Файл | За что отвечает | +|---|---| +| `src/config.js` | настройки из окружения | +| `src/portal.js` | взять задание, скачать файлы, отчитаться | +| `src/session.js` | жив ли вход, оба признака «слетел» | +| `src/cabinet.js` | загрузка строго по разметке | +| `src/browser.js` | постоянный профиль браузера, единственный тянет Playwright | +| `src/human.js` | пауза; отдельно нарочно, чтобы проверки шли без Playwright | +| `src/runner.js` | один проход целиком + сторож | +| `src/mailer.js` | письма «алярм» и «готово» | +| `bin/login.js` · `bin/keepalive.js` · `bin/run.js` | вход глазами · тихий заход · один проход | +| `README.md` | человеческим языком, включая что делать по каждому письму | + +**Решения, не пересматривать:** «прихожая» (`YC_ANTEROOM_CAMPAIGN_ID` + +`YC_ANTEROOM_GROUP_ID`) · приговор об успехе выносит портал, не робот · при сбое отчёт +порталу ПЕРВЫМ, письмо вторым · картинки клиента стираются при любом исходе · логин +кабинета `YC_CABINET_LOGIN` идёт параметром `ulogin`. + +**Тесты-сторожа, ломать нельзя** (проверены вырезанием): «НИКОГДА не нажимает Сохранить +изменения» и «ждёт, пока кнопка Создать разблокируется» (`bots/yandex-creatives/test/cabinet.test.js`). + +--- + +## 5. Четыре выхода снятия заморозки денег — канон + +Проверено 27.07.2026. Их ровно четыре, все достижимы, все покрыты тестами. **Пятого +не создавать.** + +| # | Файл:строка | Когда срабатывает | +|---|---|---| +| 1 | `app/app/Services/Advertising/CampaignImpressionCharger.php:82` | `delivered >= paid_impressions` → `completed` | +| 2 | `app/app/Jobs/SyncCampaignModerationJob.php:130` | Яндекс отклонил ВСЕ объявления → `rejected` | +| 3 | `app/app/Listeners/PauseCampaignsOnAdStop.php:48` | событие `AdvertisingStopped` → `stopped_no_funds` | +| 4 | `app/app/Http/Controllers/Api/AdvertisingCampaignController.php:356` | клиент нажал «пауза» | + +Заморозка ставится в двух местах: `CampaignLauncher.php:180` (запуск) и +`AdvertisingCampaignController.php:378` (возобновление). Списание: `CampaignImpressionCharger.php:47` +и `ChargeCampaignSpendJob.php:81`. + +**Правка Р1 ниже добавляет пятый повод снять заморозку — по истечении срока показа.** +Это осознанное расширение канона: делать его **тем же вызовом `release()` внутри перехода +в `completed`**, а не новым отдельным выходом. То есть выход №1 получает второе условие, +новых мест вызова `release()` не появляется. Убедись, что вызовов `release(` в коде +осталось **четыре**. + +--- + +## 6. 🔴 ПРИЁМОЧНЫЙ ЛИСТ ПОЧИНКИ — главная работа этой сессии + +Полный разбор ветки провели четыре проверяющих; каждую находку ниже **контроллер +перепроверил сам по коду**. Ничего из этого на бою нет: рубильник выключен, ветка +не выкачена, робот не установлен. Поэтому горит не «срочно», а «до задачи 16». + +Порядок — по опасности. Каждая правка: **сначала тест, который краснеет**, потом код. +После каждой — полный прогон обоих наборов тестов. + +### Р1 🔴 Деньги клиента замерзают навсегда + +**Где:** `app/app/Services/Advertising/CampaignImpressionCharger.php:72-82`. + +**Что не так.** Единственный переход в `completed` — `delivered >= paid_impressions`. +Задачи, которая закрывала бы кампанию по истечении срока показа, в расписании +(`app/routes/console.php`) **нет вообще**. `CampaignLauncher.php:209` ставит Яндексу +`EndDate = сегодня + run_days`; после этой даты показы прекращаются, `delivered` замирает, +смета не добирается, статус вечно `running`, заморозка `ACTIVE` навсегда. Для медийки +по списку телефонов (аудитория ≥100, ограничение частоты) **недокрут — типовой исход**. + +**Как чинить.** Сохранять дату конца показа на кампании при запуске (сейчас она нигде +не хранится, считается на лету в `resolvePeriod()`). В суточном `ChargeCampaignSpendJob` +после списания: если срок показа истёк — перевести в `completed` и снять заморозку **тем же +кодом, что и выход №1** (см. §5). Не плодить новый вызов `release()`. + +**Тест:** кампания `running`, срок истёк, `delivered < paid_impressions` → после джоба +статус `completed`, заморозка снята, деньги вернулись на баланс. + +### Р2 🔴 Отчёт робота «готово» принимается по любому заданию в любом состоянии + +**Где:** `app/app/Http/Controllers/Api/CreativeRobotController.php:89` — +`AdCreativeJob::findOrFail($jobId)`. + +**Что не так.** Ни статус, ни принадлежность не сверяются, номер задания берётся из адреса +как есть. Последствия: `{ok:true}` по чужому `queued`-заданию разложит номера креативов +**чужой кампании** по её баннерам — картинка клиента А уедет в объявление клиента Б. +`{ok:false}` по уже `done`-заданию перепишет правильный результат на `failed`. + +**Как чинить.** `AdCreativeJob::where('id', $jobId)->where('status', AdCreativeJob::STATUS_TAKEN)->firstOrFail()`. +То же условие продублировать внутри `CreativeJobService::complete()` (`:78`) и `fail()` (`:109`) — +сервис зовут не только из контроллера. + +**Тесты (негативные, их сейчас нет вообще):** `done` по `queued` → 409; по `done` → 409; +по `failed` → 409; повторный `done` → 409. + +### Р3 🔴 «Задание выдаётся строго по одному» не гарантировано + +**Где:** `app/app/Services/Advertising/CreativeJobService.php:49-56`. + +**Что не так.** + +```php +$inFlight = AdCreativeJob::where('status', STATUS_TAKEN)->exists(); // ← без блокировки +if ($inFlight) return null; +$job = AdCreativeJob::where('status', STATUS_QUEUED)->orderBy('id')->lockForUpdate()->first(); +``` + +Проверка `exists()` не заблокирована. Два одновременных `GET /next`: обе транзакции видят +«в работе никого», T1 берёт задание #1 и коммитит, T2 упирается в блокировку строки #1, +после коммита Postgres перепроверяет `status='queued'`, строка #1 уже не подходит — и T2 +забирает задание **#2**. Итог: два задания `taken` одновременно. `lockForUpdate()` защищает +от выдачи одного задания дважды, но не от выдачи двух заданий сразу. + +Дальше: слепки `creatives.get` двух кампаний перемешиваются; размеры IAB у всех одинаковые +(300×250, 728×90), поэтому либо громкий отказ, либо **тихая привязка чужого номера**. +И `CreativeRobotController.php:68` берёт `->first()` без `orderBy` — при двух `taken` робот +может получить 404 на свои же файлы. + +Комментарий в `CreativeRobotController.php:63-64` («заданий в работе всегда не больше одного — +это обеспечивает `takeNext()`») сейчас **врёт**, а на нём держится защита выдачи файлов. + +**Как чинить** — любое из двух, лучше первое: + +- частичный уникальный индекс, гарантия на уровне БД: + `CREATE UNIQUE INDEX uq_creative_job_single_taken ON ad_creative_jobs ((status)) WHERE status = 'taken';` +- либо `DB::select("SELECT pg_advisory_xact_lock(hashtext('ad_creative_jobs_take'))")` + первой строкой транзакции в `takeNext()`. + +Плюс: привязать выдачу файла к конкретному заданию — `/jobs/{jobId}/banners/{bannerId}/file` +вместо «какое-нибудь taken». Это правка **и на стороне робота** (`src/portal.js`), и в +`README.md`, и в `.env.example`, если адрес там фигурирует. + +**Миграция** → обязательно `rls-reviewer` и запись в `db/CHANGELOG_schema.md`. + +### Р4 🔴 Ручки баннеров не заперты после заведения кампании в Яндексе + +**Где:** `app/app/Http/Controllers/Api/AdvertisingCampaignController.php:622` (`uploadBanner`), +`:674` (`toggleBannerIncluded`), `:687` (`deleteBanner`). + +**Что не так.** Замок «кампания заведена в Яндексе — параметры не менять» стоит только +на `update()` (`:158-166`). Ручки баннеров не проверяют ни статус, ни `yandex_campaign_id`. + +- **Чужая картинка:** кампания в `pending_moderation`, клиент перезаливает 300×250. + `updateOrCreate` (`:664`) сохраняет строку, а `yandex_creative_id`/`yandex_ad_id` остаются + от старого креатива. В портале новая картинка, в Яндексе крутится старая. Молча. +- **Дубли:** запуск оборвался (в Яндексе есть кампания, группа и часть объявлений, статус + ещё `draft`). Клиент удаляет баннер и заливает заново → строка с `yandex_ad_id` уничтожена + → при возобновлении `CampaignLauncher.php:167-177` видит `yandex_ad_id = null` и создаёт + **второе объявление того же размера**. Старое остаётся крутиться. Это живые деньги. + +**Как чинить.** Тот же замок по `yandex_campaign_id !== null` на все три ручки (409 +с человеческим текстом). Удаление баннера с непустым `yandex_ad_id` запретить всегда. + +### Р5 🔴 У «запустить» нет защиты от повторного нажатия + +**Где:** `app/app/Http/Controllers/Api/AdvertisingCampaignController.php:258-301` → +`app/app/Services/Advertising/CampaignLauncher.php:51`. + +**Что не так.** Проверка `status in [draft, queued]` — обычное чтение без `lockForUpdate` +и без атомарного перевода статуса. Два параллельных POST проходят оба, оба читают +`yandex_campaign_id = 0` со своих экземпляров модели и идут по всей цепочке: два сегмента, +два ретаргетинга, **две CPM-кампании**, две группы, два комплекта объявлений. Заморозка +при этом одна (`freeze()` идемпотентен) — портал спишет за одну, а крутиться будут две. + +Ответ 202 `creatives_pending` (`:283`) прямо приглашает нажать ещё раз — как только ручка +появится в интерфейсе, повтор станет штатным сценарием. + +**Как чинить.** `lockForUpdate()` внутри транзакции + атомарный перевод в промежуточный +статус (`launching`), либо `Cache::lock("ad-campaign-launch:{$id}")` на входе. +⚠️ `cache_locks` в проекте нет (см. комментарии в `app/routes/console.php:37-40`) — значит +берём вариант с `lockForUpdate` и статусом. + +### Р6 🔴 Очередь робота встаёт колом навсегда + +**Где:** `bots/yandex-creatives/src/runner.js:80` и +`app/app/Http/Controllers/Api/CreativeRobotController.php:86`. + +**Что не так.** Портал валидирует `'reason' => [..., 'max:1024']`. Робот шлёт +`` `${step}: ${e.message}` ``, а сообщения Playwright при таймауте штатно содержат `Call log:` +на десятки строк и перешагивают 1024 — то есть **самый частый вид сбоя** и есть тот, чей +отчёт портал отвергнет (422). Пустой `catch { /* портал недоступен */ }` это проглотит, +задание останется `taken`, а `takeNext()` при живом `taken` возвращает `null` **всем**: +ни одна кампания больше не стартует, все клиенты вечно получают 202 «попробуйте позже». +Реаниматора нет: `taken_at` пишется, но нигде не читается; `attempts` инкрементится, но +лимита нет. + +Второй путь в тот же тупик: `complete()` (`CreativeJobService.php:94`) ходит в живой Яндекс, +`YandexDirectClient::call()` бросает `RuntimeException` на любую ошибку API, а `done()` +ловит **только** `CreativeMatchFailedException` (`CreativeRobotController.php:99`) → 500 +роботу, задание навсегда `taken`. + +**Как чинить (три части):** + +1. Робот: обрезать причину до ~900 знаков перед отправкой; **не глотать** ошибку + `reportFailure` молча, а класть её текст в письмо-алярм («порталу доложить не смог: …»). +2. Портал: в `done()` ловить `Throwable`, переводить задание в `failed` с причиной. +3. Портал: реаниматор — команда/джоб «вернуть в `queued`, если `taken_at` старше N минут + и `attempts < K`, иначе `failed`», повесить в `app/routes/console.php`. + +### Р7 🔴 Робот доложит «сбой» об успешной работе + +**Где:** `bots/yandex-creatives/src/runner.js:56` (внутри `try` со строк 31–75) и `:80`. + +**Что не так.** Если `portal.reportDone()` бросит по любой причине (502 от nginx, обрыв, +таймаут), исключение уйдёт в `catch`, и робот пошлёт `reportFailure` — **на удачно +загруженные креативы**. Портал пометит задание сбойным, а креативы уже в кабинете. Хуже: +если портал успел выполнить `complete()` (номера проставлены, статус `done`), а ответ +потерялся — `fail()` перепишет `done` → `failed` поверх правильного результата. + +Это буквально нарушение решения «приговор об успехе выносит портал», пробитое на транспорте. + +**Как чинить.** Вынести `reportDone` за пределы блока, чей `catch` зовёт `reportFailure`. +Обернуть собственным `try` с 2–3 повторами; если так и не прошло — **только письмо +человеку**, но **никогда** `reportFailure`. + +Заодно (тот же класс): `bots/yandex-creatives/src/cabinet.js:111` — уход со страницы после +«Создать» не обёрнут, сетевая икота там читается как провал загрузки. Обернуть +`.catch(() => {})`. + +### Р8 🔴 Бесконечная заливка мусора в живой кабинет + +**Где:** `app/app/Services/Advertising/CreativeIdMatcher.php:27-56` и +`app/app/Services/Advertising/CreativeJobService.php:80-106`. + +**Что не так.** `match()` с пустым списком ожидаемых размеров просто не входит в цикл +и **молча возвращает пустоту** — исключения нет. А `$banners` в `complete()` читается +без фильтра по tenant и держится на посреднике `admin-db`. Если после выката не перезапустить +`db/03_service_bypass_policies.sql` (о чём предупреждают сами миграции), `crm_admin_user` +не получит `srv_bypass` и увидит **0 строк** → `$expected = []` → `match()` вернёт `[]` → +задание помечается `DONE` при том, что ни одному баннеру номер не проставлен. + +Дальше `launch()` снова видит `whereNull('yandex_creative_id')` → 202 → `enqueue()` → робот +заливает все файлы заново → снова `DONE`-с-нулём. **Бесконечный цикл, каждый круг оставляет +15 мусорных креативов в живом кабинете, и в журнале всё зелёное.** Ровно класс «тихий ноль» +из памяти проекта. + +**Как чинить.** В `match()` бросать `CreativeMatchFailedException` при `$expectedSizes === []`. +В `complete()` — `if ($banners->isEmpty()) throw …` **до** похода в Яндекс. + +--- + +## 7. Важные хвосты — чинить в той же сессии, после Р1–Р8 + +Порядок внутри списка свободный. Каждый — с тестом. + +### Портал + +- **П1.** `AdvertisingCampaignController.php:281` — `enqueue()` стоит **до** `try {` (`:290`) + и внутри делает синхронный вызов к Директу без таймаута. Яндекс лёг → клиент получает + голый 500. Обернуть, отдать 503/202 с человеческим текстом, задать таймаут явно. +- **П2.** `CreativeJobService.php:30-44` — повторная постановка после `failed` берёт **свежий** + `snapshot_before`, в котором уже залитые креативы видны → `match()` снова падает + «не появился креатив размера X», и так по кругу, каждая попытка = новая пачка дублей + в кабинете. Чинить: переиспользовать `snapshot_before` прежнего задания при повторе, + либо заводить задание только на баннеры без `yandex_creative_id` и отдавать роботу только их. +- **П3.** `CreativeJobService.php:42` — слепок «до» снимается при постановке, а не при выдаче. + Два задания в очереди получают одинаковый слепок; второе падает **всегда**. Снимать слепок + в `takeNext()`, в той же транзакции, что и перевод в `TAKEN`. +- **П4.** `CampaignLauncher.php:172` — `addCpmBannerAd($adGroupId, $banner->yandex_creative_id, $href)` + берёт номер на веру. `YandexDirectClient::getCreativePreview()` (`:169`) уже возвращает + `Width`/`Height`. Сверять «размер креатива == размер баннера» перед `Ads.add` — одна проверка + закрывает весь класс «клиент получил чужую картинку», включая опечатку оператора, который + вписывает номер руками. +- **П5.** Нет уникального индекса `(tenant_id, campaign_id, width, height)` на + `ad_campaign_banners` (миграция `2026_07_26_100200:23` — только обычный индекс). + `updateOrCreate` (`AdvertisingCampaignController.php:664`) без него не атомарен: две + одновременные загрузки одного слота дают две строки, `bannerSlots()` (`:740`) одну молча + теряет через `keyBy`, а `CampaignLauncher.php:167` пройдёт по обеим и создаст **два + одинаковых объявления**. Миграция → `rls-reviewer` + CHANGELOG. +- **П6.** `YandexDirectClient.php:202-218` — `listImageCreativeIds()` не листает страницы + (`SelectionCriteria => []`, `LimitedBy` игнорируется, потолок Яндекса 10 000). А архитектура + мусорит: робот заливает **все** включённые баннеры (`CreativeRobotController.php:42`, + `CreativeJobService.php:80` — нет `whereNull('yandex_creative_id')`), каждая неудачная + попытка +15 креативов навсегда. Постраничный обход + отдавать роботу только баннеры + без номера. +- **П7.** Замок на правку привязан только к `yandex_campaign_id` + (`AdvertisingCampaignController.php:161`), а сегмент Аудиторий создаётся **раньше** кампании + Директа (`CampaignLauncher.php:114-119`). Обрыв в этом окне → параметры аудитории ещё + редактируются, а возобновление переиспользует старый сегмент. Замок расширить: + `yandex_segment_id !== null || yandex_campaign_id !== null`. +- **П8.** `CreativeRobotController.php:78` — файл всегда отдаётся с расширением `.jpg`, + хотя `BannerUploadPolicy::FORMATS` разрешает `png` и `gif`. Брать + `pathinfo($banner->path, PATHINFO_EXTENSION)`. Парная правка в роботе — `runner.js:38` + (см. Р-хвост ниже). + +### Деньги + +- **Д1.** `CampaignLauncher.php:192-196` — финальный `update()` не пишет `client_cpm_rub`. + Если поле пустое, `AdCampaign::effectiveCpm()` (`:123-133`) читает **текущее** + `ad_settings.client_cpm_rub`; админ поменял глобальную цену → запущенная кампания списывает + по новой, выше замороженной. Дописать `'client_cpm_rub' => $clientCpm` в тот же `update()`. +- **Д2.** `ChargeCampaignSpendJob.php:80` читает кампанию **без** `lockForUpdate()`, а + `external_key` зависит от числа показов (`CampaignImpressionCharger.php:53`). Два + одновременных прогона получат чуть разные `delivered` → разные ключи → уникальный индекс + не сработает. Добавить `lockForUpdate()`. +- **Д3.** `AdvertisingCampaignController.php:349/356` — `callDirect()` **глотает** ошибку + Директа (`:429-434`), а заморозка снимается безусловно. Запрос не прошёл → реклама крутится, + деньги свободны. При неудачном `suspendCampaign` заморозку не снимать, вернуть честное + «не удалось остановить, попробуйте ещё раз». +- **Д4.** `AdWalletService.php:130-133` — при нехватке денег баланс зажимается в `0.00`, + а проводка пишется на **полную** сумму (`:173`). Сумма проводок перестаёт сходиться + с балансом, а `AdminAdvertisingController::spend()` считает выручку и маржу суммой + `w.amount_rub` → **отчёт по марже завышен**. Решить, как правильно: писать проводку + на фактически списанное либо отдельной строкой «недобор». +- **Д5.** Рубильник. `CreativeJobService.php:135-141` строит `YandexDirectClient` безусловно — + служебный канал робота (`done()` → `complete()` → `listImageCreativeIds()`) идёт мимо + проверки. Добавить проверку внутрь `client()`, как в `CampaignLauncher.php:34`. +- **Д6.** 🪤 `AdvertisingCampaignController.php:495` — `config('services.yandex_direct.enabled') === false`. + Значение приходит из `env('YANDEX_DIRECT_ENABLED', false)`; при `YANDEX_DIRECT_ENABLED=0` + в `.env` Laravel вернёт строку `"0"`, она **не** `=== false`, и запрос уйдёт в Яндекс. + Все остальные места проверяют через `! config(…)` или `!== true`. Привести к общему виду. + Сегодня не стреляет (в `.env` стоит `false`), но форма мины — ровно та, что уже ловилась. + +### Робот + +- **Р-х1.** `bin/run.js:1`, `bin/login.js:1`, `bin/keepalive.js:1` — `import 'dotenv/config'` + резолвит `.env` от каталога запуска. Папки для картинок и снимков от каталога отвязаны + правильно (`runner.js:7`), а `.env` — нет. Заменить на + `dotenv.config({ path: fileURLToPath(new URL('../.env', import.meta.url)) })`. +- **Р-х2.** `src/browser.js:11` — `launchPersistentContext(config.profileDir)`; `README.md:66` + предписывает гонять `run:once` часто и `keepalive` раз в ~15 минут. Chromium держит + `user-data-dir` под замком, второй процесс не стартует → падение внутри `try` → задание + помечается сбойным, хотя ничего не сломано. Файл-замок в `ROBOT_ROOT`: при занятом замке + оба выходят с кодом 0. +- **Р-х3.** `src/mailer.js:14` и `:20` — текст письма жёстко зашит: «Робот остановился + и ничего в кабинете не менял», «Кампания осталась черновиком, деньги не потрачены». + А из `runner.js:65` этим же письмом сообщают «Окно загрузки не закрылось» — там робот + файлы **залил** и уже отчитался «готово». Человек прочтёт, что ничего не произошло, + и не пойдёт смотреть кабинет. `README.md:75-77` описывает правильное поведение — письмо + ему противоречит. Развести два письма. +- **Р-х4.** 🪤 **Тест-пустышка** `test/runner.test.js:110-118` («файлы клиента живут в рабочей + папке робота, а не там, откуда его запустили»): тест всегда передаёт `workDir` явно, поэтому + значение по умолчанию `workDir = ROBOT_ROOT` не проверяется вообще. Проверено вырезанием: + замена умолчания на `process.cwd()` оставляет **9/9 зелёными**. Написать настоящий тест — + вызвать `runOnce` без `workDir` из другого каталога. +- **Р-х5.** `src/runner.js:38` — все файлы называются `WxH.jpg` независимо от настоящего + формата. Брать расширение из `Content-Type` ответа портала (парно с П8). Там же: два баннера + одного размера перезапишут друг друга — имя строить от `banner_id`. +- **Р-х6.** `src/config.js:32` — `Number(env.HUMAN_DELAY_MS ?? '800')`: пустая строка в `.env` + даёт 0, а не 800 (`??` ловит только `undefined`). Там же `Number(SMTP_PORT)` может дать `NaN` + без внятной ошибки. + +--- + +## 8. Мелочи — записать, чинить по остатку времени + +- `src/portal.js:28` — `downloadBanner` шлёт токен на адрес, который прислал сервер, без + сверки, что он ведёт на `portalBaseUrl`. Сейчас безопасно, хардненинг на будущее. +- `src/runner.js:25` — папка `screenshots/` не чистится никогда, растёт бесконечно; на снимках + видна боковая панель кабинета с логином и остатком счёта. +- `src/cabinet.js:120` — `locator.isDisabled()` считает элемент заблокированным только + по атрибуту. Если `CanvasFooter.SubmitButton` окажется стилизованным `div` (как + `Html5Dropzone.UploadButton` и `CanvasFooter.CancelButton` — дока §2 отмечает это явно), + ожидание молча вернётся мгновенно и робот нажмёт вслепую. 🔑 **Проверить в первую очередь + на первом боевом прогоне (задача 17)** — это единственное место разметки, помеченное + «не измерено живьём». +- `routes/web.php:367` — порядок посредников `['admin-db','creative-robot']`: соединение + переключается **до** проверки токена. Запросов к БД без токена не делается, но правильнее + проверять токен первым. +- `CreativeRobotController.php:68` — `->first()` без `orderBy`. +- `SyncCampaignModerationJob.php:87` — `'moderation_status' => $info['status'] ?? null`, + а колонка `NOT NULL DEFAULT 'draft'` (миграция `2026_07_27_100000:34`), и это **вне** + try/catch → падает весь обход, остальные кампании не опрашиваются. Там же `moderation_reason` + — `varchar(255)`, а `StatusClarification` у Яндекса бывает длиннее. +- `db/schema.sql` **не содержит ни одной таблицы рекламного модуля** — накопленный дрейф + репозитория (в каждой записи CHANGELOG написано «DDL — в дельта-миграции»), но файл объявлен + источником истины в CLAUDE.md §0. Закрыть отдельным canon-sync, не в этой ветке. +- `CampaignLauncher.php` — `$banners->firstWhere('yandex_creative_id', null)` сравнивает + нестрого, `0` тоже считается «нет номера». Безвредно, но лучше строгий колбэк. +- `BannerGenerator.php:39-45` — цикл подбора качества упирается в пол 40 и **молча возвращает + файл любого веса**. Плюс `512000` захардкожен в сигнатуре (`:17`) вместо `BannerUploadPolicy::MAX_BYTES`. +- 🪤 **Тест-пустышка про кап 512 000:** `BannerUploadPolicyTest.php:8` и + `CampaignBannerEndpointsTest.php:31` сравнивают константу саму с собой — вырежи правило + `max:` из валидации (`AdvertisingCampaignController.php:630`), оба останутся зелёными. +- `CampaignBannerService` и `BannerGenerator` — мёртвый продуктовый код (на них не ссылается + ничего, кроме собственных тестов), при этом `generate()` (`CampaignBannerService.php:29-32`) + удаляет все строки баннеров вместе с `yandex_ad_id`. Не удалять без разрешения владельца, + но помнить. +- `YandexDirectClient.php:214` — `$creative['Width']` без `?? 0`. +- Лаунчер не проверяет уникальность `yandex_creative_id` внутри набора: один номер, вписанный + оператором в два баннера, даст два объявления с одной картинкой не того размера. +- `AdvertisingCampaignController.php:71` и `:146` принимают `client_cpm_rub` **от клиента**, + а `CampaignLauncher.php:96-100` считает от него ставку Яндекса. Кражи денег не даёт, но + параметр, управляющий ставкой в живом кабинете, лучше не отдавать клиенту. +- `budget_rub` показывается клиенту (`AdvertisingCampaignController.php:50`), валидируется, + и **больше нигде не используется** — реальный потолок это `paid_impressions`. Наследие + модели «за клики». + +--- + +## 9. Что подтверждено хорошим — не переделывать + +Проверяющие ломали защиты и смотрели, краснеют ли тесты. Настоящее: + +- Выходов снятия заморозки ровно четыре, все достижимы, все покрыты тестами. +- Деньги везде на `bcmath` scale 2, ни одного `float`. Замок по строке кошелька берётся + **до** чтения баланса и **до** проверки идемпотентности (`AdWalletService.php:122-128`). +- Идемпотентность списания подпёрта БД: `unique(tenant_id, external_key)`. +- Заморозка тает при списании (`AdWalletService.php:141-167`) с защитой «не размораживаем + больше, чем зарезервировано». +- Потолок показов: `min($deliveredImpressions, $paid)` + монотонный `charged_client_rub`. +- Наценка скрыта: `yandex_cost_rub` в `$hidden`, `ad_margin_percent` нет ни в одной клиентской + или роботной ручке. +- Токен канала: `hash_equals`, пустой токен = канал закрыт (401), а не открыт без секрета. +- Выдача файла фильтруется по `campaign_id` задания в работе + `included` — утёкший токен + не даст вычерпать чужие картинки (при условии, что Р3 починен). +- RLS: `tenant_isolation` + `FORCE RLS`; GRANT'ы выданы **именно `crm_admin_user`** — той роли, + на которую переключает `UseAdminConnection`; нумератор выдан `crm_app_user`; + `db/03_service_bypass_policies.sql` таблично-агностичен (цикл по `pg_tables WHERE rowsecurity`). +- CSRF-исключение на месте — `bootstrap/app.php:75-77`. +- Возобновляемый запуск: номер каждой сущности Яндекса пишется сразу, «группа+аудитория» — + одним неделимым шагом с записью id последним; два теста на обрыв считают вызовы и доказывают, + что кампании-сироты не появляется. +- Модерация действительно по баннерам, агрегация корректна. +- Два сторожа робота — настоящие, проверены вырезанием. + +--- + +## 10. Как запускать проверки + +```bash +cd bots/yandex-creatives && node --test # 35 тестов, браузер не нужен +cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising +``` + +🔴 База для тестов — **только** `liderra_testing_reklama`. Никогда не `liderra` (боевая) +и не `liderra_testing` (общая, её ломает параллельная сессия). + +🪤 **`npm install` локально НЕ делали** — тянет браузеры Playwright (~1 ГБ). +`src/browser.js` локально не импортируется, проверяется только `node --check`. Зависимости +ставим на боевом, в задаче 16. + +🪤 Тесты гонять **по одному процессу за раз** (см. §3 п. 8). + +--- + +## 11. 🔴 Грабли окружения + +- `composer pint` без путей проходит по всему складу и затирает чужие незакоммиченные правки — + только `cd app && ./vendor/bin/pint <файл> <файл>`. +- PowerShell-строки в bash не работают, только heredoc. +- **`.git` в worktree — ФАЙЛ, не папка.** Текст коммита класть в `$(git rev-parse --git-dir)/…`. +- `git status --cached` не существует. +- Новые файлы `git commit <пути>` не берёт — нужен предварительный `git add` тех же явных путей. +- **Bash помнит текущий каталог между вызовами** — сверяться через `pwd`, пути абсолютные. +- `markdownlint --fix` чинит чужие ошибки — гонять без `--fix`. +- Larastan и lefthook в worktree не работают; коммит с `LEFTHOOK_EXCLUDE=larastan`. +- `tinker` без `DB_DATABASE` уходит в dev-базу. +- `AdminAdvertisingSpendTest` иногда плавает от мусора в тестовой базе — чужой, не чинить. +- 🔴 `db/03_service_bypass_policies.sql` **перезапустить после выката новых таблиц**, иначе + служебные роли увидят ноль (см. Р8). +- Квирк 107: `config:cache` на бою только от `www-data`. +- Каталог `/моя/` в `.gitignore` — новые файлы `git add` пропускает молча, нужен `git add -f`. + +--- + +## 12. Проверено на боевой базе 27.07 — не перепроверять + +Право на нумератор у `crm_app_user` есть у всех семи рекламных таблиц; `ad_campaign_banners` +и `ad_creative_jobs` на бою нет; в модуле прогрева дырки в правах нет. + +--- + +## 13. Открытое — решает владелец + +1. 🔴 **Какую кампанию с группой назначить «прихожей»?** Рекомендация — отдельная пустая + **остановленная** кампания, чтобы робот не подходил к работающей рекламе. Нужно к задаче 16. +2. **Корень проблемы с правами** — `ALTER DEFAULT PRIVILEGES` в `db/02_grants.sql` без + `FOR ROLE crm_migrator`. +3. **Соседние ручки баннеров не под замком** — теперь это не открытый вопрос, а правка Р4. + Открытым остаётся вопрос спеки §10 про смежные ручки. +4. **Имя креатива можно задавать своё** — опознание стало бы прямым, но грузить пришлось бы + по одному и зависеть от Яндекса. Ядро не трогаем. +5. **Потерянная чужая правка** в `CampaignImpressionChargerTest.php`. +6. **Три устаревших промта** (`chast4-v3`, `chast4-v4`, `robot-kreativov-v8`) — удалять только + с разрешения. +7. **Д4 (клип баланса)** — как правильно писать проводку при нехватке денег: на фактически + списанное или отдельной строкой «недобор». Влияет на отчёт по марже. + +--- + +## 14. Дальше — задачи 16 и 17 + +План: `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md`. +Спека: `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +Разметка: `bots/yandex-creatives/docs/cabinet-flow.md`. + +**16 — установка на боевом.** Только с «go», сначала `prod-deploy-validator`. Поставить робота, +`npm install` + браузеры, заполнить `.env` включая «прихожую», завести вход глазами через +удалённый рабочий стол внутри виртуального экрана, повесить расписание на `run:once` +и `keepalive`. Наружу порт не открывать. 🔴 После выката новых таблиц — перезапустить +`db/03_service_bypass_policies.sql`. + +**17 — живая сверка.** Только вместе с владельцем. 🔑 На этом прогоне добираем то, чего мы +**ни разу не видели живьём**: сколько ждать после «Создать», как выглядит отказ «файл +не подошёл» и настоящая ли кнопка «Создать» (см. §8 про `isDisabled`). + +`rls-reviewer` на каждую миграцию, TDD по-настоящему. + +--- + +## 15. Суть проекта + +Портал заводит медийную кампанию «за показы» через API Яндекс Директа целиком, кроме одного +шага: **картиночный креатив через API создать нельзя** — `creatives.add` принимает только +видео, конструктор закрыт 01.06.2026. Медийная кампания = **объявление на каждый размер**, +в живой кампании `713051718` их 15. Поэтому **робот** относит готовые файлы в кабинет, +а **ядро** работает с «креатив у каждого баннера». Номера портал добывает слепком +`creatives.get` до и после. Робот номера не читает, кампаний не создаёт, показы не запускает, +к деньгам не прикасается. + +### Решения владельца — не пересматривать + +| Вопрос | Решение | +|---|---| +| Границы робота | Только грузит файлы и говорит «готово» | +| Как получает работу | Сам опрашивает портал, сам отчитывается | +| Кто готовит файлы | **Только клиент грузит готовые** | +| Набор размеров | Целевой 15; минимум один | +| Где живёт робот | **На боевом сервере** | +| Вход в кабинет | Экран на боевом + удалённый рабочий стол, только `127.0.0.1` | +| Живой кабинет | **Только вместе с владельцем** | + +### Проверенные факты + +`creatives.get` отдаёт `Id`, `Type`, `Name`, `Width`, `Height`, `PreviewUrl` · загруженные JPG +хранятся типом **`HTML5_CREATIVE`** · **имена для опознания не годятся**: поле имени одно +на всю загрузку · `getAdsModeration()` даёт `ACCEPTED`/`REJECTED`/`MODERATION` · боевой сервер: +12 ядер, 36 ГБ, Node 20, браузера нет · размеры 15 объявлений: 336×280, 480×320, 160×600, +320×50, 320×100, 300×250, 320×480, 300×500, 970×250, 1000×120, 240×600, 240×400, 728×90, +300×300, 300×600. + +--- + +## 16. Чего НЕ делать + +Не выдумывать селекторы · не переезжать на «перформанс»-кампанию · не удалять +`CampaignBannerService` / `BannerGenerator` без разрешения · не включать рубильник Директа · +**не создавать пятый вызов `release()`** (см. §5) · не переписывать возобновляемый запуск, +замки и очередь целиком — чинить точечно по §6 · **не заставлять робота выносить приговор +об успехе** — это дело портала · не давать роботу нажимать `SaveBannerButton` · +не гонять тесты несколькими процессами одновременно. + +--- + +## 17. Открытое по продукту + +Мусор в кабинете при перезаливке · лимиты Яндекса на число креативов и темп · сколько живёт +вход без действий · как выглядит ошибка «файл не подошёл». + +--- + +Отчитывайся владельцу простым языком, без программистских слов. На коммит и на всё +необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v13.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v13.md new file mode 100644 index 00000000..af72caaa --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v13.md @@ -0,0 +1,169 @@ +# Промт перезапуска v13 — реклама «за показы», починка по приёмочному листу + +**Дата:** 27.07.2026. **Не заменяет** v12 (`2026-07-27-PROMPT-restart-robot-kreativov-v12.md`) — +там лежит **приёмочный лист целиком** (§6 дыры, §7 хвосты, §8 мелочи, §9 «не переделывать», +§13 открытые вопросы владельца). Этот файл — только «где ты сейчас» и что делать дальше. + +--- + +## 0. 🔴 ПЕРВОЕ ДЕЙСТВИЕ — прочитать два файла целиком + +Не пересказывай их по памяти. Открой и прочитай: + +1. `docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md` — приёмочный лист. +2. `docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md` — что уже починено и как проверено. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Laravel-приложение — в подпапке `app/`. Робот — в `bots/yandex-creatives/`. + +Сверь `git log --oneline -4`. Должно быть так: + +```text +b8f75b2a fix реклама за показы: файл робота только по своему заданию, замок на баннерах, защита от двойного запуска +d26716ed fix реклама за показы: срок показа закрывает кампанию, отчёт робота только по заданию в работе, гонка выдачи заданий +6c63389b docs робот креативов: промт перезапуска v12 — полный разбор ветки и приёмочный лист починки +b66009bd docs робот креативов: промт перезапуска v11 — задачи 12-15 закрыты, дальше боевой сервер +``` + +Никуда не пушили. Незакоммичено только три устаревших промта (`chast4-v3`, `chast4-v4`, +`robot-kreativov-v8`) — **не читай, не коммить, удалять только с разрешения владельца**. + +Зелёная отметка на 27.07.2026, прогнано в одиночку: + +- портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + → **266/266, 936 проверок, ~44 с**; +- робот: `cd bots/yandex-creatives && node --test` → **35/35**; +- `cd app && npm run type-check` → 6 ошибок, **все чужие**, ровно те же и без наших правок + (проверено `git stash`). Не чинить в этой ветке. + +--- + +## 1. Что из приёмочного листа закрыто + +| # | Дыра | Состояние | +|---|---|---| +| Р1 | Деньги клиента замерзали навсегда при недокруте сметы | ✅ `d26716ed` | +| Р2 | Отчёт робота принимался по любому заданию в любом состоянии | ✅ `d26716ed` | +| Р3 | Два задания в работе одновременно + выдача файла «какому-нибудь» | ✅ `d26716ed` + `b8f75b2a` | +| Р4 | Ручки баннеров не заперты после заведения кампании в Яндексе | ✅ `b8f75b2a` | +| Р5 | У «запустить» не было защиты от повторного нажатия | ✅ `b8f75b2a` | +| Р6 | Очередь робота встаёт колом навсегда | ⬜ **следующая** | +| Р7 | Робот докладывает «сбой» об успешной работе | ⬜ | +| Р8 | Бесконечная заливка мусора в живой кабинет | ⬜ | + +Хвосты П1–П8, Д1–Д6, Р-х1–Р-х6 (v12 §7) и мелочи (v12 §8) — **не начаты**. + +Что появилось нового и о чём надо знать: + +- колонка `ad_campaigns.shows_until` + запись **v9.07** в `db/CHANGELOG_schema.md`; +- частичный уникальный индекс `uq_creative_job_single_taken` + запись **v9.08**; +- новый статус `AdCampaign::STATUS_LAUNCHING` (`launching`) — промежуточный, держится секунды. + Он добавлен и во фронт: тип `CampaignStatus` в `app/resources/js/api/advertising.ts` и **обе** + карты `STATUS_META` (`CampaignList.vue`, `CampaignReportDialog.vue`) — `Record` + требует все ключи, иначе `vue-tsc` падает; +- адрес файла робота сменился на `GET /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file`. + +--- + +## 2. Р6 — очередь робота встаёт колом навсегда. Следующая работа + +**Где:** `bots/yandex-creatives/src/runner.js:80` и +`app/app/Http/Controllers/Api/CreativeRobotController.php` (метод `done()`). + +**Что не так.** Портал валидирует `'reason' => [..., 'max:1024']`. Робот шлёт +`` `${step}: ${e.message}` ``, а сообщения Playwright при таймауте штатно содержат `Call log:` +на десятки строк и перешагивают 1024 — то есть **самый частый вид сбоя** и есть тот, чей отчёт +портал отвергнет (422). Пустой `catch { /* портал недоступен */ }` это проглотит, задание +останется `taken`, а `takeNext()` при живом `taken` возвращает `null` **всем**: ни одна кампания +больше не стартует, все клиенты вечно получают 202 «попробуйте позже». Реаниматора нет: +`taken_at` пишется, но нигде не читается; `attempts` инкрементится, но лимита нет. + +Второй путь в тот же тупик: `complete()` ходит в живой Яндекс, `YandexDirectClient::call()` +бросает `RuntimeException` на любую ошибку API, а `done()` ловит **только** +`CreativeMatchFailedException` → 500 роботу, задание навсегда `taken`. + +**Как чинить (три части):** + +1. Робот: обрезать причину до ~900 знаков перед отправкой; **не глотать** ошибку `reportFailure` + молча, а класть её текст в письмо-алярм («порталу доложить не смог: …»). +2. Портал: в `done()` ловить `Throwable`, переводить задание в `failed` с причиной. +3. Портал: реаниматор — команда/джоб «вернуть в `queued`, если `taken_at` старше N минут + и `attempts < K`, иначе `failed`», повесить в `app/routes/console.php`. + +Дальше — **Р7 и Р8 по тексту v12 §6**, потом хвосты §7, потом мелочи §8. + +--- + +## 3. Как работать + +1. **Каждая правка: сначала тест, который краснеет, потом код.** Убедись, что красный — + по правильной причине, а не из-за опечатки. +2. **Защиту проверять вырезанием**: временно сломай боевой код, убедись, что тест краснеет, + верни обратной заменой. +3. После каждой правки — **полный прогон обоих наборов**. +4. **Отчёты проверяющих проверяй сам по коду.** +5. **Денежный диф читать глазами.** Выходов снятия заморозки денег ровно **четыре** (таблица + в v12 §5). Пятого не создавать. Убедись, что вызовов `release(` в коде осталось четыре. +6. Миграция → обязательно `rls-reviewer` и запись в `db/CHANGELOG_schema.md`. +7. **За собой убирать.** В конце `git status --porcelain` — только три старых промта. + +### 🪤 Грабли, пойманные в этих сессиях + +- 🪤 **`git checkout -- <файл>` после проверки вырезанием откатывает файл к коммиту**, то есть + сносит и саму правку, а не только мутацию. Возвращать мутацию обратной `sed`-заменой либо + делать `cp` файла в `/tmp` перед мутацией. +- 🪤 **Тест может быть ложно зелёным на незнакомом адресе**: неизвестный URL перехватывает + страница сайта и отвечает 200. Голого `assertOk()` мало — проверять содержимое. +- 🪤 **Тест на защиту может быть пустышкой**, если он сам создаёт то состояние, которое должна + создавать защита. Вырезание защиты — единственный честный способ это увидеть. Так вскрылось + дважды: тест на захват кампании и тест «файлы клиента живут в рабочей папке робота» (Р-х4). +- 🪤 **Тесты гонять по одному процессу за раз.** База только `liderra_testing_reklama`. + Никогда не `liderra` (боевая) и не `liderra_testing` (общая, её ломает параллельная сессия). +- 🪤 `->after('колонка')` в миграции на PostgreSQL — **no-op**, колонка встаёт в конец таблицы. +- 🪤 `composer pint` без путей проходит по всему складу и затирает чужие незакоммиченные + правки — только `cd app && ./vendor/bin/pint <файл> <файл>`. +- 🪤 **`.git` в worktree — ФАЙЛ, не папка.** Текст коммита класть в `$(git rev-parse --git-dir)/…`. +- 🪤 Новые файлы `git commit <пути>` не берёт — нужен предварительный `git add` тех же путей. + Каталог `/моя/` в `.gitignore` — там нужен `git add -f`. +- 🪤 Larastan и lefthook в worktree не работают; коммит с `LEFTHOOK_EXCLUDE=larastan`. + «Can't find lefthook in PATH» — ожидаемо, коммит проходит. +- 🪤 Хуки в worktree немые — gitleaks вручную: по индексу + `"…/Документация/bin/gitleaks.exe" protect --staged` (бинарь лежит в **корневом** репо, + в worktree только `.gitkeep`). +- 🪤 Bash помнит текущий каталог между вызовами — сверяться через `pwd`, пути абсолютные. +- 🪤 `tinker` без `DB_DATABASE` уходит в dev-базу. +- 🪤 `AdminAdvertisingSpendTest` иногда плавает от мусора в тестовой базе — чужой, не чинить. +- 🪤 `app/app/Listeners/PauseCampaignsOnAdStop.php` git иногда помечает изменённым, а содержимое + побайтово совпадает с коммитом. Причуда Windows, не трогать. + +--- + +## 4. 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, 403. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца** через AskUserQuestion, метка опции = точная git-команда. + Сообщение **без круглых скобок**, `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- 🔴 `db/03_service_bypass_policies.sql` **перезапустить после выката новых таблиц**, иначе + служебные роли увидят ноль (это уже висит на таблице `ad_creative_jobs`, запись v9.06). +- Владелец — **не программист**. Отчитываться простым русским, без программистских слов. +- **Останавливаться на логичных точках**, а не лететь без остановки. + +--- + +## 5. Дальше — задачи 16 и 17 (после починки) + +**16 — установка на боевом.** Только с «go», сначала `prod-deploy-validator`. +**17 — живая сверка.** Только вместе с владельцем. + +Подробности — v12 §14. Открытые вопросы владельца — v12 §13 (в том числе: какую кампанию +назначить «прихожей», Д4 про проводку при нехватке денег, судьба трёх устаревших промтов). + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v14.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v14.md new file mode 100644 index 00000000..5c26cbe8 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v14.md @@ -0,0 +1,189 @@ +# Промт перезапуска v14 — реклама «за показы», все восемь дыр закрыты, дальше хвосты + +**Дата:** 27.07.2026. **Не заменяет** v12 (`2026-07-27-PROMPT-restart-robot-kreativov-v12.md`) — +там лежит **приёмочный лист целиком**: §6 дыры (закрыты), **§7 хвосты — твоя работа**, +§8 мелочи, §9 «не переделывать», §13 открытые вопросы владельца. Этот файл — только +«где ты сейчас» и что делать дальше. Предыдущий v13 устарел. + +--- + +## 0. 🔴 ПЕРВОЕ ДЕЙСТВИЕ — прочитать два файла целиком + +Не пересказывай их по памяти. Открой и прочитай: + +1. `docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md` — приёмочный лист. + **Главное там сейчас — §7 «Важные хвосты» и §8 «Мелочи».** +2. `docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md` — что уже починено и как проверено. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Laravel-приложение — в подпапке `app/`. Робот — в `bots/yandex-creatives/`. + +Сверь `git log --oneline -4`. Должно быть так: + +```text +542b7d2d fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится +43be8c39 docs робот креативов: промт перезапуска v13 — Р1-Р5 закрыты, дальше Р6 +b8f75b2a fix реклама за показы: файл робота только по своему заданию, замок на баннерах, защита от двойного запуска +d26716ed fix реклама за показы: срок показа закрывает кампанию, отчёт робота только по заданию в работе, гонка выдачи заданий +``` + +Никуда не пушили. Незакоммичено только три устаревших промта (`chast4-v3`, `chast4-v4`, +`robot-kreativov-v8`) — **не читай, не коммить, удалять только с разрешения владельца**. + +Зелёная отметка на 27.07.2026, прогнано в одиночку: + +- портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + → **274/274, 957 проверок, ~45 с**; +- робот: `cd bots/yandex-creatives && node --test` → **41/41**; +- `cd app && npm run type-check` → 6 ошибок, **все чужие**, ровно те же и без наших правок + (проверено `git stash` в прошлой сессии). Не чинить в этой ветке. +- вызовов `AdWalletService->release(` в коде — **четыре**, пятого не появилось. Проверяй это + число после каждой денежной правки; прочие `->release()` в репозитории — снятие замков + `Cache::lock`, не деньги. + +--- + +## 1. Что закрыто — не переделывать + +| # | Дыра | Коммит | +|---|---|---| +| Р1 | Деньги клиента замерзали навсегда при недокруте сметы | `d26716ed` | +| Р2 | Отчёт робота принимался по любому заданию в любом состоянии | `d26716ed` | +| Р3 | Два задания в работе одновременно + выдача файла «какому-нибудь» | `d26716ed` + `b8f75b2a` | +| Р4 | Ручки баннеров не заперты после заведения кампании в Яндексе | `b8f75b2a` | +| Р5 | У «запустить» не было защиты от повторного нажатия | `b8f75b2a` | +| Р6 | Очередь робота встаёт колом навсегда | `542b7d2d` | +| Р7 | Робот докладывал «сбой» об успешной работе | `542b7d2d` | +| Р8 | Бесконечная заливка мусора в живой кабинет | `542b7d2d` | + +**Все восемь — с тестами, каждая защита проверена вырезанием.** Подробности по каждой — +в PROGRESS-файле. + +Что появилось нового и о чём надо знать, чтобы не сломать: + +- колонка `ad_campaigns.shows_until` + запись **v9.07** в `db/CHANGELOG_schema.md`; +- частичный уникальный индекс `uq_creative_job_single_taken` + запись **v9.08**. + 🪤 Из-за него в базе может быть **не больше одного** задания со статусом `taken` — + тест, создающий два, упадёт на нарушении уникальности, и это правильно; +- статус `AdCampaign::STATUS_LAUNCHING` (`launching`) — промежуточный, держится секунды. + Добавлен и во фронт: тип `CampaignStatus` в `app/resources/js/api/advertising.ts` и **обе** + карты `STATUS_META` (`CampaignList.vue`, `CampaignReportDialog.vue`) — `Record` + требует все ключи, иначе `vue-tsc` падает; +- адрес файла робота — `GET /api/creative-robot/jobs/{jobId}/banners/{bannerId}/file`; +- команда `creative-jobs:reap` (`app/app/Console/Commands/ReapStuckCreativeJobs.php`) — + сторож зависших заданий, в расписании каждые 10 минут. Порог 30 минут, 3 попытки. + 🔴 Ходит через `pgsql_admin`: на дефолтной роли без tenant-контекста RLS отдал бы ноль строк; +- у робота новый ключ `REPORT_RETRY_DELAY_MS` (по умолчанию 3000) — пауза между повторами + доклада «готово». В `.env.example` он уже есть. + +--- + +## 2. Твоя работа — хвосты по листу v12 §7 + +**Порядок внутри списка свободный, каждый — с тестом.** Полный текст каждого хвоста — +в v12 §7, здесь только карта, чтобы понимать масштаб. + +**Портал П1–П8:** П1 обрыв Яндекса даёт клиенту голый 500 · П2 повторная постановка после +сбоя берёт свежий слепок и падает по кругу, плодя дубли · П3 слепок снимается при постановке, +а не при выдаче · П4 номер креатива берётся на веру, размер не сверяется · П5 нет уникального +индекса на слот баннера (**миграция** → `rls-reviewer` + CHANGELOG) · П6 нет постраничного +обхода креативов + роботу отдаются все баннеры, а не только без номера · П7 замок на правку +не покрывает окно между сегментом и кампанией · П8 файл всегда отдаётся как `.jpg`. + +**Деньги Д1–Д6:** Д1 не пишется `client_cpm_rub` — списание может уехать по новой цене · +Д2 нет `lockForUpdate` в суточном списании · Д3 ошибка Директа глотается, а заморозка +снимается безусловно · **Д4 — открытый вопрос владельца**, не решай сам (см. §4) · Д5 рубильник +Директа обходится служебным каналом робота · Д6 сравнение `=== false` вместо `! config(…)`. + +**Робот Р-х1–Р-х6:** Р-х1 `.env` резолвится от каталога запуска · Р-х2 два процесса не +поделят профиль браузера, нужен файл-замок · Р-х3 письмо врёт «ничего не менял», когда файлы +уже залиты · **Р-х4 тест-пустышка** про рабочую папку — написать настоящий · Р-х5 расширение +файла и имя по `banner_id` (парно с П8) · Р-х6 `Number(env.X ?? '800')` — пустая строка даёт 0, +`SMTP_PORT` может дать `NaN`. + +🔎 Половина Р-х6 уже закрыта побочно: ключ `REPORT_RETRY_DELAY_MS` добавлен сразу в правильной +форме, но `HUMAN_DELAY_MS` и `SMTP_PORT` — с прежней миной. + +Дальше — **мелочи v12 §8**, потом задачи 16 и 17 (см. §5). + +--- + +## 3. Как работать + +1. **Каждая правка: сначала тест, который краснеет, потом код.** Убедись, что красный — + по правильной причине, а не из-за опечатки. +2. **Защиту проверять вырезанием**: временно сломай боевой код, убедись, что тест краснеет, + верни обратно. +3. После каждой правки — **полный прогон обоих наборов**. +4. **Отчёты проверяющих проверяй сам по коду.** +5. **Денежный диф читать глазами.** Выходов снятия заморозки ровно **четыре** (таблица + в v12 §5). Пятого не создавать. +6. Миграция → обязательно `rls-reviewer` и запись в `db/CHANGELOG_schema.md`. +7. **За собой убирать.** В конце `git status --porcelain` — только три старых промта. + +### 🪤 Грабли, пойманные в этих сессиях + +- 🪤 **Копируй файл в scratchpad перед мутацией и возвращай оттуда.** `git checkout -- <файл>` + откатывает файл к коммиту, то есть сносит и саму правку, а не только мутацию. +- 🪤 **Тест может быть ложно зелёным на незнакомом адресе**: неизвестный URL перехватывает + страница сайта и отвечает 200. Голого `assertOk()` мало — проверять содержимое. +- 🪤 **Тест на защиту может быть пустышкой**, если он сам создаёт то состояние, которое должна + создавать защита. Вырезание защиты — единственный честный способ это увидеть. Так вскрылось + дважды; третий известный случай ждёт починки — Р-х4. +- 🪤 **`pgsql_admin` в тестах виден**: `tests/Concerns/SharesAdminPdo.php` подключён глобально + к `Feature` (`tests/Pest.php`), обе connection делят один PDO. Поэтому команду можно честно + прибить к `pgsql_admin`. **Обратная сторона:** мутация «сменить соединение на дефолтное» + в тестах НЕ покраснеет — это чисто боевая защита, держится на знании про RLS. +- 🪤 **Тесты гонять по одному процессу за раз.** База только `liderra_testing_reklama`. + Никогда не `liderra` (боевая) и не `liderra_testing` (общая, её ломает параллельная сессия). +- 🪤 `->after('колонка')` в миграции на PostgreSQL — **no-op**, колонка встаёт в конец таблицы. +- 🪤 `composer pint` без путей проходит по всему складу и затирает чужие незакоммиченные + правки — только `cd app && ./vendor/bin/pint <файл> <файл>`. +- 🪤 **`.git` в worktree — ФАЙЛ, не папка.** Текст коммита класть в `$(git rev-parse --git-dir)/…`. +- 🪤 Новые файлы `git commit <пути>` не берёт — нужен предварительный `git add` тех же путей. + Каталог `/моя/` в `.gitignore` — там нужен `git add -f`. +- 🪤 Larastan и lefthook в worktree не работают; коммит с `LEFTHOOK_EXCLUDE=larastan`. + «Can't find lefthook in PATH» — ожидаемо, коммит проходит. +- 🪤 Хуки в worktree немые — gitleaks вручную по индексу: + `"…/Документация/bin/gitleaks.exe" protect --staged` (бинарь лежит в **корневом** репо, + в worktree только `.gitkeep`). +- 🪤 Bash помнит текущий каталог между вызовами — сверяться через `pwd`, пути абсолютные. +- 🪤 `tinker` без `DB_DATABASE` уходит в dev-базу. +- 🪤 `AdminAdvertisingSpendTest` иногда плавает от мусора в тестовой базе — чужой, не чинить. +- 🪤 `app/app/Listeners/PauseCampaignsOnAdStop.php` git иногда помечает изменённым, а содержимое + побайтово совпадает с коммитом. Причуда Windows, не трогать. + +--- + +## 4. 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, 403. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца** через AskUserQuestion, метка опции = точная git-команда. + Сообщение **без круглых скобок**, `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- 🔴 `db/03_service_bypass_policies.sql` **перезапустить после выката новых таблиц**, иначе + служебные роли увидят ноль (висит на таблице `ad_creative_jobs`, запись v9.06). +- **Открытые вопросы владельца не закрывать самому** — v12 §13. В том числе **Д4** (как писать + проводку при нехватке денег) и выбор «прихожей». +- Владелец — **не программист**. Отчитываться простым русским, без программистских слов. +- **Останавливаться на логичных точках**, а не лететь без остановки. + +--- + +## 5. Дальше — задачи 16 и 17 (после хвостов) + +**16 — установка на боевом.** Только с «go», сначала `prod-deploy-validator`. +**17 — живая сверка.** Только вместе с владельцем. + +Подробности — v12 §14. 🔑 На первом живом прогоне добираем то, чего мы ни разу не видели: +сколько ждать после «Создать», как выглядит отказ «файл не подошёл» и настоящая ли кнопка +«Создать» (v12 §8 про `isDisabled`). + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v6.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v6.md new file mode 100644 index 00000000..8f54f308 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v6.md @@ -0,0 +1,135 @@ +# Промт перезапуска v6 — робот-грузчик креативов + переход ядра на набор креативов + +> Скопируй текст ниже целиком в новую сессию. + +--- + +Продолжаем рекламу Лидерры «за показы» в Яндексе. Дизайн и план уже написаны — **твоя работа +начинается со стройки, а не с проектирования**. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Где именно стоит ветка — посмотри сам: `git log --oneline -3`. Опорный коммит с находкой, +спекой и снимками кабинета — `d3bfa2b0`. + +## 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` / `lk.liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка + боевой БД, включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний в боевом + кабинете **не создавать** до отдельного «go». +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован** (account suspended, 403) — пуш **только в gitea**. +- Коммиты — **по эскейпу владельца**, сообщение **без круглых скобок**, + `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- В ветке лежит **чужая незакоммиченная правка** `app/tests/Feature/Advertising/CampaignImpressionChargerTest.php` — + **не коммитить как своё, не трогать**. +- Владелец — не программист. Говорить простым русским, без программистских слов. + +## Что делать первым делом + +1. Прочитать **план**: `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md` — 17 задач + в трёх фазах, с готовым кодом и командами в каждом шаге. +2. Прочитать **спеку**: `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +3. Прочитать **находку-основание**: `docs/superpowers/findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md`. +4. Начать с **Задачи 1** и идти по порядку. Способ исполнения — спросить у владельца: + субагенты по задачам (рекомендуется) либо инлайн через `superpowers:executing-plans`. + +⚠️ Первым делом — `git status`. Если файл плана или этого промта ещё **не закоммичен**, +попросить у владельца эскейп и закоммитить, прежде чем что-то менять в коде. +В рабочей папке при этом должна остаться нетронутой чужая правка `CampaignImpressionChargerTest.php`. + +## Суть проекта в двух абзацах + +Портал заводит медийную кампанию «за показы» в Директе через API целиком, кроме одного шага: +**картиночный креатив через API создать нельзя** — `creatives.add` принимает только видео. +Раньше считалось, что можно сделать «один адаптивный креатив из одной картинки в Конструкторе +Яндекса». **27.07.2026 живая проверка боевого кабинета это опровергла:** конструктор закрыт +01.06.2026, Яндекс пишет об этом плашкой в редакторе объявления, показы его креативов +остановлены с 30.06. Медийная кампания устроена как **объявление на каждый размер блока** +со своим креативом — это видно в живой кампании прогрева `№713051718` из 15 объявлений. + +Поэтому: **робот** относит готовые файлы клиента в веб-кабинет через «Загрузить креативы», а +**ядро** переходит с «один креатив на кампанию» на «креатив у каждого баннера». Номера креативов +портал добывает сам — слепком `creatives.get` до и после загрузки, сопоставляя по `Width`/`Height`. +Робот номера не читает, кампаний не создаёт, показы не запускает, к деньгам не прикасается. + +## Решения владельца — не пересматривать + +| Вопрос | Решение | +|---|---| +| Границы робота | Только грузит файлы и говорит «готово». Всё остальное — портал через API | +| Как получает работу | Сам опрашивает портал, сам отчитывается. Без человека | +| Кто готовит файлы | **Только клиент грузит готовые.** Портал ничего не режет | +| Набор размеров | Целевой — 15 (`BannerSizes`); клиент грузит сколько есть, минимум один | +| Где живёт робот | **На боевом сервере**, рядом с Лидеррой | +| Вход в кабинет | Экран на боевом + удалённый рабочий стол, **слушают только `127.0.0.1`**, доступ через SSH-туннель | +| Объём | Робот и переделка ядра — одной работой | + +Осознанные последствия (озвучены, приняты): клиент обязан принести файлы точных размеров ≤ 512 КБ; +готовый резчик `CampaignBannerService` / `BannerGenerator` остаётся неиспользованным и **не удаляется**; +живой вход в кабинет с деньгами лежит на боевом сервере. + +## Проверенные факты — на них опирается весь код + +- `creatives.get` **работает**, отдаёт `Id`, `Type`, `Name`, `Width`, `Height`, `PreviewUrl`. +- Загруженные JPG Яндекс хранит типом **`HTML5_CREATIVE`** (не «image»). Отбирать по нему. +- **Имена не годятся для опознания** — всем загруженным Яндекс ставит «Новый креатив». +- `PreviewUrl` приходит — своё хранилище превью не нужно. +- Боевой сервер: 12 ядер, 36 ГБ памяти, 66 ГБ диска, нагрузка 0.00, Node 20 стоит, браузера нет. +- Меню креатива: кнопка `[data-testid="ImageCreativeEditor.OpenMenu"]`, пункты «Готовые креативы + HTML5» / «Загрузить креативы» / «Выбрать из ранее загруженных». + +## Что уже есть в коде — не переписывать + +- Клиентская загрузка баннера с проверкой точного размера, формата и веса — `AdvertisingCampaignController::uploadBanner`. +- **Один файл на размер** гарантирован ключом `updateOrCreate(tenant_id, campaign_id, width, height)`. +- Медийные методы Директа — `YandexDirectClient::addCpmBannerCampaign/AdGroup/MediaAudienceTarget/CpmBannerAd/getCreativePreview`. +- Заморозка/разморозка денег на 4 выходах кампании — чинилась коммитом `dd894a52`, **поведение не менять**. +- Образец робота — `bots/mts-telegram-ads/` (вход, keep-alive, сторож, письма). +- Образец сервис-токена — `App\Http\Middleware\SalesIntegrationToken` + алиас в `bootstrap/app.php`. + +## Две вещи, которые план чинит попутно + +1. **Кап веса баннера 150 КБ → 512 КБ.** Стоял с пометкой «сверка с Яндексом — Часть 4». Сверено. +2. **Один забракованный баннер валит всю оплаченную кампанию.** Новое правило: кампания работает, + если принято **хотя бы одно** объявление; в «отклонено» с разморозкой денег — только когда + отклонены **все**. Так не появляется пятый выход кампании. + +## Грабли окружения + +- 🔴 **`db/03_service_bypass_policies.sql`** — после добавления новых таблиц **перезапустить** на + проде, иначе служебные роли молча увидят ноль строк (тихий ноль при зелёных тестах). +- **Larastan в worktree не запускается** (junction-vendor, `composer stan` exit 1 с пустым выводом) — + опираться на Pest, `composer stan` гонять на обычном чекауте. +- **Хуки в worktree не срабатывают** («Can't find lefthook in PATH») — gitleaks гонять вручную: + `./bin/gitleaks.exe detect --source .claude/worktrees/reklama-pokazy --log-opts " -1"`. +- Тесты — `liderra_testing`: `cd app && php artisan test --filter=Advertising`. + Если база отстала: `DB_DATABASE=liderra_testing php artisan migrate --force`. +- CHANGELOG схемы править **в worktree** `db/CHANGELOG_schema.md`, не в основной папке репо. +- Основная рабочая папка стоит на чужой ветке `fix/warming-existing-manager-dedup` — **не трогать**. +- Квирк 107: `config:cache` на проде — **только от www-data**, не от root. + +## Обязательные проверки по ходу + +- `rls-reviewer` — на каждую миграцию (задачи 1 и 7). +- `prod-deploy-validator` — перед выполнением runbook установки (задача 16). +- `superpowers:requesting-code-review` — на всю ветку перед живой сверкой (задача 17). +- TDD по-настоящему: сначала падающий тест, увидеть падение, потом код. Не наоборот. + +## Чего НЕ делать + +- Не выдумывать селекторы кабинета — снять живьём (задача 11), ничего в кабинете **не сохраняя**. +- Не обходить намеренную заглушку в `cabinet.js` до заполнения `docs/cabinet-flow.md`. +- Не переезжать на «перформанс»-кампанию ради авто-подгонки из одной картинки — это отказ от + модели «за показы». +- Не удалять `CampaignBannerService` / `BannerGenerator` — отдельное решение владельца. +- Не включать рубильник Директа самостоятельно. + +## Открытое, выяснить по ходу (спека §10) + +Мусор в кабинете при перезаливке набора · лимиты Яндекса на число креативов и темп загрузки · +сколько живёт вход в кабинет без действий · точные экраны загрузки. + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v7.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v7.md new file mode 100644 index 00000000..2997b433 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v7.md @@ -0,0 +1,207 @@ +# Промт перезапуска v7 — хвосты фазы 1, затем фазы 2 и 3 робота креативов + +> Скопируй текст ниже целиком в новую сессию. + +--- + +Продолжаем рекламу Лидерры «за показы» в Яндексе. Дизайн и план написаны, **фаза 1 уже сделана +и закоммичена**. Твоя работа — сначала закрыть два хвоста, потом строить дальше по плану. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Фаза 1 — коммит `c9406533`. Сверь `git log --oneline -3`. + +## 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован** (account suspended, 403) — пуш **только в gitea**. Пока не пушили. +- Коммиты — **по эскейпу владельца**, сообщение **без круглых скобок**, + `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- В ветке лежит **чужая незакоммиченная правка** + `app/tests/Feature/Advertising/CampaignImpressionChargerTest.php` — **не коммитить, не трогать**. +- Владелец — не программист. Говорить простым русским, без программистских слов. + +## Как мы работаем — протокол, который уже обкатан + +1. **Строим субагентами.** На каждую задачу — свежий субагент, полный текст задачи в промте + (субагент план **не читает**, ты даёшь текст сам). +2. **Субагентам коммитить запрещено.** Ни `commit`, ни `add`, ни `stash`, ни смена ветки. + Коммитит контроллер по эскейпу владельца, в конце фазы, явными путями. +3. **До и после каждого субагента** — git-протокол Pravila §15.1: запомнить + `git branch --show-current` и `git rev-parse HEAD`, после возврата сверить. Разошлось — стоп. +4. **Денежный диф читать глазами самому**, не верить отчёту субагента. Уже был случай, когда + субагент ослаблял боевое правило ради зелёного теста. +5. **Хуки в worktree немые** («Can't find lefthook in PATH») — gitleaks гонять вручную: + `./bin/gitleaks.exe detect --source .claude/worktrees/reklama-pokazy --log-opts " -1"`. + +## 🔴 СНАЧАЛА — два хвоста фазы 1. Не оставлять их на потом + +### Хвост A: обрыв запуска на середине оставляет кампанию-сироту в Яндексе + +**Что не так.** `CampaignLauncher::launch()` идёт так: рубильник → проверки баннеров → расчёт → +создаёт в Яндексе сегмент, ретаргетинг-список, кампанию, группу, таргет → в цикле по баннерам +создаёт объявления (номер каждого сразу пишется на баннер) → **и только в самом конце** замораживает +деньги и записывает на кампанию `yandex_campaign_id`, `yandex_ad_group_id`, статус и `launched_at`. + +Если связь оборвётся на третьем объявлении из пятнадцати — в кабинете Яндекса останутся кампания, +группа и три объявления, а портал будет считать, что запуска **не было**: у кампании пусто, статус +`draft`. Повторный запуск заведёт **вторую** кампанию, а первая останется висеть. Риск был и до +перехода на набор, но с одним объявлением; теперь их до пятнадцати. + +**Что сделать.** Сделать запуск **возобновляемым**, а не переписывать его целиком: + +1. Записывать номера Яндекса на кампанию **по ходу**, сразу после создания каждой сущности + (`yandex_retargeting_list_id` → `yandex_campaign_id` → `yandex_ad_group_id`), а не одним + `update()` в конце. Тогда после обрыва портал знает, что уже создано. +2. При повторном запуске **переиспользовать** уже созданное: если у кампании есть + `yandex_campaign_id` — не создавать вторую, брать существующую; то же для группы и + ретаргетинг-списка. Объявления создавать **только для баннеров, у которых `yandex_ad_id` пуст**. +3. 🔴 **Деньги — самое опасное место.** Заморозка стоит в конце и при повторном запуске сработает + **второй раз**. Это надо закрыть: замораживать только если по этой кампании заморозки ещё нет. + Посмотри, как `AdWalletService` хранит холд, и сделай проверку по факту, а не по догадке. + Существующие **четыре** выхода снятия заморозки менять нельзя — их чинили коммитом `dd894a52`. +4. Тесты обязательны: (а) обрыв на середине набора и повторный запуск не создаёт вторую кампанию + в Яндексе и не задваивает объявления; (б) повторный запуск **не** замораживает деньги дважды; + (в) обычный запуск с нуля работает как раньше. + +Если по ходу окажется, что чистого решения без переделки денежной части нет — **остановись и +спроси владельца**, не изобретай пятый выход заморозки. + +### Хвост B: у роли админки нет прав на таблицу баннеров + +**Что не так.** `crm_admin_user` имеет `SELECT` на `ad_campaigns`, `ad_campaign_ads`, +`ad_campaign_phones` (миграция `2026_07_25_100300_grant_admin_read_advertising.php`), но на +`ad_campaign_banners` — **ни одной привилегии**: таблица создана позже раздачи прав. На боевом +кластере эта роль **не** BYPASSRLS, поэтому админ-экран, начав читать статус модерации по баннерам, +молча увидит пустоту — тот самый «тихий ноль». + +**Что сделать.** Отдельная маленькая миграция: `GRANT SELECT, UPDATE ON ad_campaign_banners TO +crm_admin_user` под тем же гардом на `pg_roles`, что и в +`2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php`. `UPDATE` нужен, потому что оператор +должен уметь вписать номер креатива руками, когда робот не справился. Симметричный `down()`. +Запись в `db/CHANGELOG_schema.md` + пометка перезапустить `db/03_service_bypass_policies.sql`. +Прогнать агента `rls-reviewer`. + +В журнале схемы в записи `v9.03` уже стоит предупреждение про это — **снять его**, когда почините. + +## Что делать после хвостов + +Идти по плану `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md`: + +- **Фаза 2, задачи 7–10** — таблица заданий робота `ad_creative_jobs`, сервис заданий, + канал робота (сервис-токен + три адреса), постановка задания при запуске кампании. +- **Фаза 3, задачи 11–17** — снять живую разметку кабинета, скелет робота, разговор с порталом, + браузер и загрузка, сторож и письма, установка на боевом, живая сверка. + +Спека — `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +Находка-основание — `docs/superpowers/findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md`. + +⚠️ План писался до фазы 1 и местами разошёлся с реальностью — в фазе 1 нашлись три расхождения +(неверный путь тест-файла, несуществующий файл теста, нерабочий вызов `fresh('moderation_status')`). +**Сверяй код задачи с фактическим состоянием файлов**, не тащи из плана вслепую. + +## Суть проекта в двух абзацах + +Портал заводит медийную кампанию «за показы» в Директе через API целиком, кроме одного шага: +**картиночный креатив через API создать нельзя** — `creatives.add` принимает только видео. +Конструктор креативов Яндекса **закрыт 01.06.2026**, показы его креативов остановлены с 30.06 — +проверено живьём в боевом кабинете. Медийная кампания устроена как **объявление на каждый размер +блока** со своим креативом: в живой кампании прогрева `№713051718` их 15. + +Поэтому **робот** относит готовые файлы клиента в веб-кабинет через «Загрузить креативы», а **ядро** +уже переведено на «креатив у каждого баннера». Номера креативов портал добывает сам — слепком +`creatives.get` до и после загрузки, сопоставляя по `Width`/`Height`. Робот номера не читает, +кампаний не создаёт, показы не запускает, к деньгам не прикасается. + +## Решения владельца — не пересматривать + +| Вопрос | Решение | +|---|---| +| Границы робота | Только грузит файлы и говорит «готово». Всё остальное — портал через API | +| Как получает работу | Сам опрашивает портал, сам отчитывается. Без человека | +| Кто готовит файлы | **Только клиент грузит готовые.** Портал ничего не режет | +| Набор размеров | Целевой — 15 (`BannerSizes`); клиент грузит сколько есть, минимум один | +| Где живёт робот | **На боевом сервере**, рядом с Лидеррой | +| Вход в кабинет | Экран на боевом + удалённый рабочий стол, слушают только `127.0.0.1`, доступ по SSH-туннелю | +| Объём | Робот и переделка ядра — одной работой | + +Осознанные последствия: клиент обязан принести файлы точных размеров ≤ 512 КБ; готовый резчик +`CampaignBannerService` / `BannerGenerator` остаётся неиспользованным и **не удаляется**; живой вход +в кабинет с деньгами лежит на боевом сервере. + +## Что уже сделано в фазе 1 — не переписывать + +- `ad_campaign_banners` + `yandex_creative_id`, `yandex_ad_id`, `moderation_status`, + `moderation_reason`; константы `AdCampaignBanner::MOD_DRAFT/MOD_MODERATION/MOD_ACCEPTED/MOD_REJECTED`. +- Кап веса баннера поднят 150 КБ → **512 000 байт**, в мастере клиента запасное число тоже поправлено. +- `YandexDirectClient::listImageCreativeIds()` — слепок картиночных креативов аккаунта. +- `CreativeIdMatcher` + `CreativeMatchFailedException` — опознание по разнице слепков и размеру, + наугад ничего не привязывает. +- `CampaignLauncher` заводит объявление на каждый включённый баннер, номер пишет на баннер. + `ad_campaigns.yandex_ad_id` больше не пишется, колонка оставлена как аварийный путь. +- `SyncCampaignModerationJob` считает модерацию по баннерам: кампания работает, если принято + **хотя бы одно** объявление; в «отклонено» с возвратом денег — только когда отклонены **все**. + Связь `AdCampaign::banners()` добавлена. +- 220/220 тестов рекламы зелёные, gitleaks чисто. + +## Проверенные факты — на них опирается весь код + +- `creatives.get` работает, отдаёт `Id`, `Type`, `Name`, `Width`, `Height`, `PreviewUrl`. +- Загруженные JPG Яндекс хранит типом **`HTML5_CREATIVE`** (не «image»). Отбирать по нему. +- **Имена для опознания не годятся** — всем загруженным Яндекс ставит «Новый креатив». +- `getAdsModeration()` отдаёт `[adId => ['status'=>…, 'state'=>…, 'reason'=>…]]`, статусы + passthrough (`ACCEPTED`/`REJECTED`/`MODERATION`), совпадают с нашими константами. +- Боевой сервер: 12 ядер, 36 ГБ памяти, 66 ГБ диска, нагрузка 0.00, Node 20 стоит, браузера нет. +- Меню креатива в кабинете: кнопка `[data-testid="ImageCreativeEditor.OpenMenu"]`, пункты + «Готовые креативы HTML5» / «Загрузить креативы» / «Выбрать из ранее загруженных». + +## Грабли окружения + +- 🔴 **`db/03_service_bypass_policies.sql`** — после добавления новых таблиц **перезапустить** на + проде, иначе служебные роли молча увидят ноль строк. Файл таблично-агностичный, вешает + `srv_bypass` на все RLS-таблицы; по имени новые таблицы в нём перечислять не надо. +- Роли `crm_supplier_worker`, `crm_admin_user`, `crm_app_user` **есть** и на `liderra`, и на + `liderra_testing` — не верь старым комментариям в миграциях, которые пишут обратное. +- **Larastan в worktree не запускается** (junction-vendor, `composer stan` exit 1 с пустым выводом) — + опираться на Pest, `composer stan` гонять на обычном чекауте. +- Тесты: `cd app && php artisan test --filter=Advertising`. База `liderra_testing`. + Если отстала: `DB_DATABASE=liderra_testing php artisan migrate --force`. + ⚠️ `php artisan tinker` без `DB_DATABASE=liderra_testing` уходит в **dev-базу `liderra`** — + один раз так уже случайно выдали грант не туда. +- Изредка краснеет `AdminAdvertisingSpendTest` в сквозном прогоне — смешение `DatabaseTransactions` + и `RefreshDatabase` в соседних файлах, предсуществующая нестабильность. Прогнать файл отдельно + и не «чинить». +- `configureYandex()` уже объявлена глобально в `CampaignLauncherTest.php` — в новых тест-файлах + своя функция должна называться иначе, иначе fatal. +- CHANGELOG схемы править **в worktree** `db/CHANGELOG_schema.md`. +- Основная рабочая папка стоит на чужой ветке `fix/warming-existing-manager-dedup` — **не трогать**. +- Квирк 107: `config:cache` на проде — **только от www-data**, не от root. + +## Обязательные проверки по ходу + +- `rls-reviewer` — на каждую миграцию (хвост B, задача 7). +- `prod-deploy-validator` — перед выполнением runbook установки (задача 16). +- `superpowers:requesting-code-review` — на всю ветку перед живой сверкой (задача 17). +- TDD по-настоящему: сначала падающий тест, увидеть падение своими глазами, потом код. + +## Чего НЕ делать + +- Не выдумывать селекторы кабинета — снять живьём (задача 11), ничего в кабинете **не сохраняя**. +- Не обходить намеренную заглушку в `cabinet.js` до заполнения `docs/cabinet-flow.md`. +- Не переезжать на «перформанс»-кампанию ради авто-подгонки из одной картинки — это отказ от + модели «за показы». +- Не удалять `CampaignBannerService` / `BannerGenerator` — отдельное решение владельца. +- Не включать рубильник Директа самостоятельно. +- Не создавать пятый выход снятия заморозки денег. + +## Открытое, выяснить по ходу + +Мусор в кабинете при перезаливке набора · лимиты Яндекса на число креативов и темп загрузки · +сколько живёт вход в кабинет без действий · точные экраны загрузки. + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v9.md b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v9.md new file mode 100644 index 00000000..6b6256a8 --- /dev/null +++ b/docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v9.md @@ -0,0 +1,242 @@ +# Промт перезапуска — робот-грузчик креативов, фаза 3 + +Продолжаем рекламу Лидерры «за показы» в Яндексе. **Фазы 1 и 2 сделаны и закоммичены.** +Осталась фаза 3 — сам робот. Первая же её задача требует владельца рядом, читай ниже. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Сверь состояние: `git log --oneline -3`. Верхний коммит — фаза 2 «очередь заданий робота, +служебный канал и постановка задания при запуске», под ним `af53dfed` и `c9406533`. + +## 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** Он это решил + явно 27.07.2026. Сам туда не заходи, пароль не спрашивай, обход не ищи. +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован** — account suspended, 403. Пуш **только в gitea**. + На момент написания промта не пушили никуда. +- Коммиты — **по эскейпу владельца**, сообщение **без круглых скобок**, + `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- Рядом лежат три устаревших незакоммиченных промта: + `2026-07-27-PROMPT-restart-reklama-pokazy-chast4-v3.md`, `-v4.md` и + `2026-07-27-PROMPT-restart-robot-kreativov-v8.md`. Все три неактуальны — **не читай их**, + не коммить. Удалять их можно только с разрешения владельца. +- Владелец — не программист. Говорить простым русским, без программистских слов. + +## Как мы работаем — протокол, обкатанный на фазах 1 и 2 + +1. **Строим субагентами.** На каждую задачу — свежий субагент, полный текст задачи в промте. + Субагент план **не читает**, ты даёшь текст сам. +2. **Субагентам коммитить запрещено.** Ни `commit`, ни `add`, ни `stash`, ни смена ветки. + Коммитит контроллер по эскейпу владельца, явными путями. +3. **До и после каждого субагента** — git-протокол Pravila §15.1: запомнить + `git branch --show-current` и `git rev-parse HEAD`, после возврата сверить. Разошлось — стоп. +4. **Денежный диф читать глазами самому.** Выходов снятия заморозки денег ровно **четыре** + (`->release(` у `AdWalletService`; ещё три `$lock->release()` — это замки Redis, не деньги). + Пятого не создавать. Проверка: `grep -rn "\->release(" app/app --include=*.php`. +5. **Отчёты проверяющих агентов тоже проверять** — находка агента это гипотеза, а не факт. +6. 🔴 **План сверять с живым кодом перед каждой задачей.** План писался до фазы 1. На фазе 2 + в нём нашлись **четыре ошибки**, и все они были невидимы для тестов, а вылезли бы на бою. + Подробности ниже — читай, это главный урок фазы 2. +7. **Хуки в worktree немые** — «Can't find lefthook in PATH». Gitleaks гонять вручную из корня + основного репо: `./bin/gitleaks.exe detect --source .claude/worktrees/reklama-pokazy --log-opts " -1"`. + +## 🔴 Урок фазы 2: четыре мины в плане, которые тесты не ловят + +Все четыре — один класс: **на тестах невидимо, на бою ломается**. Причина одна — тесты ходят +в базу суперпользователем и с отключёнными защитами, а бой ходит ограниченными ролями. + +1. **Не та роль базы.** План выдал права на новую таблицу роли `crm_supplier_worker`, а канал + робота ходит через посредник `admin-db`, который подменяет подключение на `crm_admin_user`. + На бою был бы отказ доступа. Права переписаны на `crm_admin_user`. +2. **Забытая защита от подделки запросов.** Канал `/api/creative-robot/*` не был внесён в список + исключений CSRF. На бою POST-отчёт робота отбивался бы ошибкой 419 — а в тестах эта проверка + движком отключена, то есть тесты зелёные при сломанном бое. +3. **Выдача чужих картинок.** План отдавал файл баннера просто по номеру. Утёкший токен позволил + бы перебором вычерпать рекламные картинки всех клиентов. Заужено: отдаётся только файл того + задания, которое сейчас в работе; на чужой баннер — 404, есть тест. +4. **Обход рубильника.** Постановка задания роботу ходит в API Яндекса за слепком креативов, и + план ставил её ДО проверки рубильника Директа. При выключенном рубильнике портал полез бы в + живой Яндекс. Ветка перенесена внутрь проверки рубильника, есть тест с `Http::assertNothingSent()`. + +**Вывод для следующей сессии:** перед каждой задачей открывай реальные файлы, которые план +собирается трогать, и проверяй его утверждения. Особенно — про роли БД, про посредники в +маршрутах и про то, что происходит при выключенном рубильнике. + +## Что уже сделано — не переписывать + +### Фаза 1, коммиты `c9406533` и `af53dfed` + +- `ad_campaign_banners` + `yandex_creative_id`, `yandex_ad_id`, `moderation_status`, + `moderation_reason`; кап веса баннера 512 000 байт. +- `YandexDirectClient::listImageCreativeIds()` — слепок картиночных креативов аккаунта. +- `CreativeIdMatcher` + `CreativeMatchFailedException` — опознание по разнице слепков и размеру. +- `CampaignLauncher` заводит объявление на каждый включённый баннер; запуск **возобновляемый** + — номера сущностей Яндекса пишутся сразу, повтор дочерчивает недостающее. +- **Замок на повторный запуск** (только из `draft`/`queued`) и **замок на правку параметров** + после заведения кампании в Яндексе (409 на всё, кроме названия). +- `SyncCampaignModerationJob` считает модерацию по баннерам. +- Права: `crm_admin_user` на `ad_campaign_banners`; нумераторы семи рекламных таблиц. +- Журнал схемы: записи `v9.03`, `v9.04`, `v9.05`. + +### Фаза 2, верхний коммит — очередь робота + +- **Таблица `ad_creative_jobs`** — очередь заданий. RLS `tenant_isolation` + `FORCE`, гранты + `crm_app_user` на чтение-вставку-правку плюс нумератор, `crm_admin_user` только чтение-правка. + Журнал схемы — запись `v9.06`. Прошла `rls-reviewer` без блокеров. +- **`CreativeJobService`** — `enqueue` / `takeNext` / `complete` / `fail`. Задания выдаются + строго по одному: два параллельных задания перемешают слепки креативов. При несовпадении + слепка не трогается ни один баннер, задание уходит в сбой с понятной причиной. +- **Служебный канал робота** `/api/creative-robot/*` — свой токен в заголовке + `X-Creative-Robot-Token`, посредник `CreativeRobotToken`, настройка + `services.creative_robot.token` из `CREATIVE_ROBOT_TOKEN`. Три адреса: взять задание, + скачать файл баннера, отчитаться. Запустить кампанию или потратить деньги через канал нельзя. +- **Запуск без картинок** больше не ошибка: портал ставит задание роботу и отвечает 202 + «Готовим картинки в рекламном кабинете» — кампания остаётся черновиком, деньги не морозятся. +- **247 из 247 тестов рекламы зелёные** в личном прогоне контроллера. + +## 🔴 Тестовая база — отдельная + +**Гонять тесты только так:** + +```bash +cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising +``` + +Общую `liderra_testing` **не трогать**: 27.07.2026 за неё дралась параллельная сессия +(ветка телеграм-рекламы в основной папке) и стёрла оттуда рекламные таблицы — журнал миграций +считал их накаченными, а на деле их не было, и прогон давал россыпь «отношение не существует». +Починить общим способом было нельзя: «пересобрать базу с нуля» снесло бы таблицы соседней сессии. + +Поэтому заведена своя база под ветку — `liderra_testing_reklama` (такие же ветковые базы уже есть +от прошлых работ: `_pausefix`, `_topup`, `_jivo`). Если её вдруг нет: + +```bash +createdb -U postgres -h 127.0.0.1 liderra_testing_reklama +cd app && DB_DATABASE=liderra_testing_reklama php artisan migrate --force +``` + +**Пока идут две сессии одновременно, зелёные тесты в общей базе ничего не значат.** + +## 🔴 Грабли, стоившие крови + +- **`composer pint` без списка путей проходится по всему складу** и затирает чужие + незакоммиченные правки. 27.07.2026 так была безвозвратно потеряна правка соседней сессии + в `CampaignImpressionChargerTest.php`. Форматировать только своё: + `cd app && ./vendor/bin/pint <файл> <файл>`. +- **Многострочный текст в стиле PowerShell при запуске через bash не работает** — символы + `@'` и `'@` попадают внутрь сообщения коммита. Для bash — обычный heredoc. +- **Инструмент Edit может перевести файл в CRLF целиком** — тогда pint краснеет по всему файлу. + Проверяй `git diff` после правки: «изменился весь файл» — верни LF. +- **`markdownlint-cli2 --fix` чинит и чужие ошибки.** В `db/CHANGELOG_schema.md` есть + предсуществующая ошибка MD032 в записи `v9.02` — её трогать не надо. Гонять **без** `--fix`. +- **Larastan в worktree не запускается** — junction-vendor, `composer stan` даёт пустой exit 1. + Опираться на Pest, `composer stan` гонять на обычном чекауте. +- **`php artisan tinker` без `DB_DATABASE` уходит в dev-базу `liderra`** — один раз так уже + случайно выдали грант не туда. +- Изредка краснеет `AdminAdvertisingSpendTest` в сквозном прогоне: он проверяет итог по всем + тенантам сразу, а соседний файл тестов кампаний работает без транзакций. Плавает: у субагента + красный, у контроллера следом зелёный. Прогнать отдельно и **не чинить** — это чужой файл. +- 🔴 **`db/03_service_bypass_policies.sql`** — после выката новых таблиц на бой **перезапустить**, + иначе служебные роли молча увидят ноль строк. Файл таблично-агностичный. +- Основная рабочая папка стоит на чужой ветке — **не трогать**. +- Квирк 107: `config:cache` на проде — **только от www-data**, не от root. + +## Что проверено на боевой базе 27.07.2026 — не перепроверять + +Владелец разрешил один читающий заход. Только `SELECT`, ни одной изменяющей команды. + +- У всех семи рекламных таблиц, живущих на бою, право на нумератор у `crm_app_user` **есть**. +- Таблиц `ad_campaign_banners` и `ad_creative_jobs` на бою **нет** — они живут только в этой + ветке. Гранты в миграциях приедут вместе с таблицами. +- Модуль прогрева: дырки в правах нет. + +## Открытое — решает владелец, сам не закрывай + +1. **Корень проблемы с правами.** В `db/02_grants.sql` строки `ALTER DEFAULT PRIVILEGES + IN SCHEMA public` написаны **без** `FOR ROLE crm_migrator`, а миграции идут под + `crm_migrator`. Из-за этого созданное миграциями дефолтных прав не наследует, и гранты + приходится дописывать руками в каждой миграции — уже дважды забывали. **Не править без «go».** +2. **Соседние ручки не под замком.** `uploadBanner`, `deleteBanner`, `toggleBannerIncluded`, + `approveBanners` не смотрят на `yandex_campaign_id`. Намеренно не закрывал: перезаливка + набора — открытый вопрос спеки §10, решать без владельца нельзя. +3. **`storePhones` под замок не ставили** намеренно: правка списка номеров применяется ночным + джобом на следующий день, это заявленное поведение. +4. **Потерянная чужая правка** в `CampaignImpressionChargerTest.php` — владелец знает, решения + не принимал. + +## Что делать дальше — фаза 3 + +План: `docs/superpowers/plans/2026-07-27-yandex-creative-robot.md`, задачи 11–17. +Спека: `docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md`. +Находка-основание: `docs/superpowers/findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md`. + +- **Задача 11 — снять живую разметку кабинета.** 🔴 **Только вместе с владельцем.** Ничего в + кабинете не сохранять, кампаний не создавать. Пока не сделана — не выдумывать селекторы. +- **Задачи 12–13** — скелет робота и конфиг, разговор с порталом. Разметка кабинета для них + **не нужна**, их можно делать до задачи 11, если владелец захочет продвинуться без него. +- **Задача 14** — браузер, проверка входа, загрузка в кабинет. Нужна разметка из задачи 11. +- **Задачи 15–17** — сторож и письма, установка на боевом, живая сверка. + +Обязательные проверки: `rls-reviewer` на каждую миграцию; `prod-deploy-validator` перед +runbook установки в задаче 16; `superpowers:requesting-code-review` на всю ветку перед живой +сверкой в задаче 17. TDD по-настоящему: сначала падающий тест, увидеть падение своими глазами. + +## Суть проекта в двух абзацах + +Портал заводит медийную кампанию «за показы» в Директе через API целиком, кроме одного шага: +**картиночный креатив через API создать нельзя** — `creatives.add` принимает только видео. +Конструктор креативов Яндекса **закрыт 01.06.2026**, показы его креативов остановлены с 30.06 — +проверено живьём. Медийная кампания устроена как **объявление на каждый размер блока** со своим +креативом: в живой кампании прогрева `№713051718` их 15. + +Поэтому **робот** относит готовые файлы клиента в веб-кабинет через «Загрузить креативы», а +**ядро** уже переведено на «креатив у каждого баннера». Номера креативов портал добывает сам — +слепком `creatives.get` до и после загрузки, сопоставляя по ширине и высоте. Робот номера не +читает, кампаний не создаёт, показы не запускает, к деньгам не прикасается. + +## Решения владельца — не пересматривать + +| Вопрос | Решение | +|---|---| +| Границы робота | Только грузит файлы и говорит «готово». Всё остальное — портал через API | +| Как получает работу | Сам опрашивает портал, сам отчитывается. Без человека | +| Кто готовит файлы | **Только клиент грузит готовые.** Портал ничего не режет | +| Набор размеров | Целевой — 15; клиент грузит сколько есть, минимум один | +| Где живёт робот | **На боевом сервере**, рядом с Лидеррой | +| Вход в кабинет | Экран на боевом плюс удалённый рабочий стол, слушают только `127.0.0.1` | +| Объём | Робот и переделка ядра — одной работой | +| Живой кабинет | **Заходить только вместе с владельцем** — решено 27.07.2026 | + +## Проверенные факты — на них опирается весь код + +- `creatives.get` работает, отдаёт `Id`, `Type`, `Name`, `Width`, `Height`, `PreviewUrl`. +- Загруженные JPG Яндекс хранит типом **`HTML5_CREATIVE`**, не «image». Отбирать по нему. +- **Имена для опознания не годятся** — всем загруженным Яндекс ставит «Новый креатив». +- `getAdsModeration()` отдаёт статусы passthrough — `ACCEPTED`/`REJECTED`/`MODERATION`. +- Боевой сервер: 12 ядер, 36 ГБ памяти, 66 ГБ диска, Node 20 стоит, браузера нет. +- Меню креатива в кабинете: кнопка `[data-testid="ImageCreativeEditor.OpenMenu"]`, пункты + «Готовые креативы HTML5» / «Загрузить креативы» / «Выбрать из ранее загруженных». + +## Чего НЕ делать + +- Не выдумывать селекторы кабинета — снять живьём в задаче 11, вместе с владельцем. +- Не обходить намеренную заглушку в `cabinet.js` до заполнения `docs/cabinet-flow.md`. +- Не переезжать на «перформанс»-кампанию ради авто-подгонки из одной картинки — это отказ от + модели «за показы». +- Не удалять `CampaignBannerService` / `BannerGenerator` — отдельное решение владельца. +- Не включать рубильник Директа самостоятельно. +- Не создавать пятый выход снятия заморозки денег. +- Не переписывать возобновляемый запуск, два замка и очередь робота — закрыты и покрыты тестами. + +## Открытое по продукту, выяснить по ходу + +Мусор в кабинете при перезаливке набора · лимиты Яндекса на число креативов и темп загрузки · +сколько живёт вход в кабинет без действий · точные экраны загрузки. + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-28-PROMPT-restart-robot-kreativov-v15.md b/docs/superpowers/2026-07-28-PROMPT-restart-robot-kreativov-v15.md new file mode 100644 index 00000000..0a19f963 --- /dev/null +++ b/docs/superpowers/2026-07-28-PROMPT-restart-robot-kreativov-v15.md @@ -0,0 +1,205 @@ +# Промт перезапуска v15 — реклама «за показы», приёмочный лист пройден, остались мелочи + +**Дата:** 28.07.2026. **Не заменяет** v12 (`2026-07-27-PROMPT-restart-robot-kreativov-v12.md`) — +там приёмочный лист целиком. Сейчас из него остались только **§8 «Мелочи»** и **§14 задачи 16–17**. +Предыдущие v13 и v14 устарели. + +--- + +## 0. 🔴 ПЕРВОЕ ДЕЙСТВИЕ — прочитать два файла целиком + +Не пересказывай их по памяти. Открой и прочитай: + +1. `docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md` — приёмочный лист. + **Главное там сейчас — §8 «Мелочи», §13 «Открытое — решает владелец», §14 задачи 16–17.** +2. `docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md` — что починено и как проверено. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Laravel-приложение — в подпапке `app/`. Робот — в `bots/yandex-creatives/`. + +Сверь `git log --oneline -4`. Должно быть так: + +```text +9cc3f5e9 fix реклама за показы: пауза не врёт про остановку, цена запуска остаётся на кампании, робот не дерётся сам с собой +0548fc0d fix реклама за показы: слепок креативов при выдаче, сверка размера перед объявлением, один баннер на слот +dcfd9fe2 docs робот креативов: промт перезапуска v14 — все восемь дыр закрыты, дальше хвосты +542b7d2d fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится +``` + +Никуда не пушили. Незакоммичено только три устаревших промта (`chast4-v3`, `chast4-v4`, +`robot-kreativov-v8`) — **не читай, не коммить, удалять только с разрешения владельца**. + +Зелёная отметка на 28.07.2026, прогнано в одиночку: + +- портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + → **293/293, 1008 проверок, ~48 с**; +- робот: `cd bots/yandex-creatives && node --test` → **57/57**; +- вызовов `AdWalletService->release(` в коде — **четыре**, пятого не появилось. Проверяй это + число после каждой денежной правки; прочие `->release()` — снятие замков `Cache::lock`. +- `npm run type-check` в этой сессии не гонялся (фронт не трогали). До Р5 там было 6 чужих + ошибок — не чинить в этой ветке. + +--- + +## 1. Что закрыто — не переделывать + +**Все восемь критичных дыр Р1–Р8** — коммиты `d26716ed`, `b8f75b2a`, `542b7d2d`. +**Все хвосты** — портал П1–П8 (`0548fc0d`), деньги Д1–Д6 кроме Д4 и робот Р-х1–Р-х6 (`9cc3f5e9`). + +Каждая правка — с тестом, каждая защита проверена вырезанием. Подробности по каждой — +в PROGRESS-файле, там же обоснования решений. + +**Что появилось нового и о чём надо знать, чтобы не сломать:** + +- колонка `ad_campaigns.shows_until` (v9.07), частичный индекс `uq_creative_job_single_taken` + (v9.08), уникальный индекс `uq_ad_campaign_banner_slot` (v9.09). 🪤 Из-за них в базе не + может быть двух заданий `taken` и двух баннеров одного размера в кампании — тест, который + их создаёт, упадёт на нарушении уникальности, и это правильно; +- статус `AdCampaign::STATUS_LAUNCHING` — промежуточный, есть и во фронте (тип `CampaignStatus` + и **обе** карты `STATUS_META`); +- команда `creative-jobs:reap` — сторож зависших заданий, каждые 10 минут, ходит через + `pgsql_admin`; +- `CreativeJobService::bannersToUpload()` — единый источник «какие баннеры везём»: + и для выдачи роботу, и для сопоставления при отчёте. Разведёшь их — опознание сломается; +- **слепок креативов снимается в `takeNext()`, внутри транзакции.** Да, это сетевой вызов + в открытой транзакции — осознанно, обосновано в комментарии. Не «оптимизируй» обратно; +- `YandexDirectClient::getCreativeSizes()` — сверка размеров перед созданием объявлений; + `listImageCreativeIds()` теперь листает страницы; +- у робота новые файлы: `src/env.js` (`.env` от корня робота), `src/lock.js` (файл-замок + `robot.lock`), `src/smtp.js` (транспорт почты вынесен, чтобы `mailer.js` был чистым и + проверяемым); +- `downloadBanner(banner, targetBase)` принимает путь **БЕЗ** расширения и возвращает + фактический — расширение берётся из типа содержимого; +- у письма-алярма появился признак `uploaded`: залиты ли уже креативы в кабинет. + +🔴 **Осознанно НЕ менялось** (не «чини»): + +- у `resume()` неудача Директа по-прежнему не откатывает статус: там заморозка ставится ДО + обращения к Яндексу, и отказ потребовал бы **пятого** места разморозки. Их ровно четыре; +- миграция уникального слота дубли не чистит — падает громко. Молча удалять картинки клиента + нельзя, а на боевом таблицы ещё нет вовсе. + +--- + +## 2. Твоя работа + +### 2.1. Мелочи — лист v12 §8 + +Полный текст там, здесь только то, что изменилось после хвостов: + +- ✅ Уже закрыто побочно: `->first()` без `orderBy` в выдаче файла (адрес привязан к заданию), + расширение файла, отсутствие постраничного обхода. +- ⚠️ Остаётся и стоит сделать: порядок посредников `['admin-db','creative-robot']` в + `routes/web.php` (токен проверять первым) · `SyncCampaignModerationJob` — `moderation_status` + вне try/catch падает на всём обходе, `moderation_reason` короче, чем бывает у Яндекса · + чистка папки `screenshots/` (растёт бесконечно, на снимках виден логин и остаток счёта) · + `downloadBanner` шлёт токен на адрес от сервера без сверки с `portalBaseUrl` · + `BannerGenerator` молча отдаёт файл любого веса · **тест-пустышка про кап 512 000** + (`BannerUploadPolicyTest`, `CampaignBannerEndpointsTest` — сравнивают константу саму с собой) · + `$banners->firstWhere('yandex_creative_id', null)` сравнивает нестрого · `Width` без `?? 0` · + `client_cpm_rub` и `budget_rub` принимаются от клиента. +- 🔴 `db/schema.sql` не содержит ни одной таблицы рекламного модуля — накопленный дрейф. + Закрывать отдельным canon-sync, **не в этой ветке**. Там же устарела шапка + `db/CHANGELOG_schema.md` («тридцать записей», «v8.85»), хотя записи ушли до v9.09. + +### 2.2. Известные остатки, найденные по ходу (в лист v12 не входили) + +- Проигравший гонку за слот баннера получит 500 от нарушения уникальности, а его файл + останется на диске сиротой. Честного теста без настоящей гонки не написать; размен + осознанный — 500 лучше двух объявлений за деньги клиента. Хочешь починить — обработать + `QueryException` 23505 в `uploadBanner()` перечитыванием строки. +- У `crm_app_user` табличный `UPDATE` на `ad_creative_jobs` без ограничения по колонкам. + Сейчас безвредно (клиентский код в `taken` не пишет), но защита держится на отсутствии + такого кода, а не на правах в БД. +- Мусор в кабинете при неудачных заходах робота вычищается только руками — открытый вопрос + по продукту (v12 §17). + +### 2.3. Дальше — задачи 16 и 17 + +**16 — установка на боевом.** Только с «go» владельца, сначала `prod-deploy-validator`. +**17 — живая сверка.** Только вместе с владельцем. + +Подробности — v12 §14. 🔑 На первом живом прогоне добираем то, чего мы ни разу не видели: +сколько ждать после «Создать», как выглядит отказ «файл не подошёл» и настоящая ли кнопка +«Создать» (v12 §8 про `isDisabled`). + +🔴 При выкате: **перезапустить `db/03_service_bypass_policies.sql`** — в ветке две новые +таблицы (`ad_campaign_banners`, `ad_creative_jobs`), иначе служебные роли увидят ноль. +В `.env` робота добавились `HUMAN_DELAY_MS`, `REPORT_RETRY_DELAY_MS` и «прихожая». + +--- + +## 3. Как работать + +1. **Каждая правка: сначала тест, который краснеет, потом код.** Убедись, что красный — + по правильной причине, а не из-за опечатки в самом тесте (так уже было с Д6). +2. **Защиту проверять вырезанием**: временно сломай боевой код, убедись, что тест краснеет, + верни обратно. +3. После каждой правки — **полный прогон обоих наборов**. +4. **Отчёты проверяющих проверяй сам по коду.** В этой сессии `rls-reviewer` дал верный GO, + но заодно заявил, что теста на индекс нет — он искал по имени индекса, а тест ловит отказ + базы. Тест был, и красным до миграции он был. +5. **Денежный диф читать глазами.** Выходов снятия заморозки ровно **четыре**. Пятого не создавать. +6. Миграция → обязательно `rls-reviewer` и запись в `db/CHANGELOG_schema.md`. +7. **За собой убирать.** В конце `git status --porcelain` — только три старых промта. + +### 🪤 Грабли, пойманные в этих сессиях + +- 🪤 **Копируй файл в scratchpad перед мутацией и возвращай оттуда.** `git checkout -- <файл>` + откатывает файл к коммиту, то есть сносит и саму правку. +- 🪤 **Тест на защиту может быть пустышкой**, если он сам создаёт то состояние, которое должна + создавать защита, или если проверяет то, что верно при обоих вариантах кода. Так вскрывалось + трижды: захват кампании, рабочая папка робота (Р-х4), слепок при выдаче (П3). +- 🪤 **Тест может быть ложно зелёным на незнакомом адресе**: неизвестный URL перехватывает + страница сайта и отвечает 200. Голого `assertOk()` мало. +- 🪤 **`Http::fake([...])` НЕ ловит незаглушенные адреса** — они уходят в настоящую сеть. + Добавил вызов к Яндексу в код — добавь заглушку во ВСЕ фейки соответствующих тестов. +- 🪤 **`final`-классы Mockery не мокает.** Ронять зависимость в тестах — через + `$this->app->bind(Класс::class, fn () => throw new ...)`. +- 🪤 **`pgsql_admin` в тестах виден** (`tests/Concerns/SharesAdminPdo.php` подключён глобально). + Обратная сторона: мутация «сменить соединение на дефолтное» НЕ покраснеет. +- 🪤 **Тесты гонять по одному процессу за раз.** База только `liderra_testing_reklama`. + Никогда не `liderra` (боевая) и не `liderra_testing` (общая). +- 🪤 **В тестовой базе копится мусор от прежних прогонов** — из-за него упала миграция + уникального слота. Чистить можно ТОЛЬКО тестовую базу, миграцию строгой не ослаблять. +- 🪤 `composer pint` без путей затирает чужие незакоммиченные правки — только + `cd app && ./vendor/bin/pint <файл> <файл>`. И перечитывай результат: pint может сдвинуть + докблок к соседнему тесту. +- 🪤 `->after('колонка')` в миграции на PostgreSQL — **no-op**. +- 🪤 **`.git` в worktree — ФАЙЛ, не папка.** Текст коммита класть в `$(git rev-parse --git-dir)/…`. +- 🪤 Новые файлы `git commit <пути>` не берёт — нужен `git add` тех же путей. + Каталог `/моя/` в `.gitignore` — там нужен `git add -f`. +- 🪤 Larastan и lefthook в worktree не работают; коммит с `LEFTHOOK_EXCLUDE=larastan`. + «Can't find lefthook in PATH» — ожидаемо. +- 🪤 Хуки в worktree немые — gitleaks вручную по индексу: + `"…/Документация/bin/gitleaks.exe" protect --staged` (бинарь в **корневом** репо). +- 🪤 `nodemailer` и `playwright` локально НЕ установлены. Модуль, который их импортирует, + нельзя проверить тестом — выноси чистую часть отдельно (`human.js`, `smtp.js`). +- 🪤 Bash помнит текущий каталог между вызовами — сверяться через `pwd`. +- 🪤 `tinker` без `DB_DATABASE` уходит в dev-базу. +- 🪤 `AdminAdvertisingSpendTest` иногда плавает от мусора в тестовой базе — чужой, не чинить. +- 🪤 `app/app/Listeners/PauseCampaignsOnAdStop.php` git иногда помечает изменённым, а + содержимое побайтово совпадает с коммитом. Причуда Windows, не трогать. + +--- + +## 4. 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, 403. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца** через AskUserQuestion, метка опции = точная git-команда. + Сообщение **без круглых скобок**, `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- **Открытые вопросы владельца не закрывать самому** — v12 §13. В том числе **Д4** (как писать + проводку при нехватке денег: на фактически списанное или отдельной строкой «недобор»; + влияет на отчёт по марже) и выбор «прихожей» для робота. +- Владелец — **не программист**. Отчитываться простым русским, без программистских слов. +- **Останавливаться на логичных точках**, а не лететь без остановки. + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-28-PROMPT-restart-robot-kreativov-v16.md b/docs/superpowers/2026-07-28-PROMPT-restart-robot-kreativov-v16.md new file mode 100644 index 00000000..f1a6e67f --- /dev/null +++ b/docs/superpowers/2026-07-28-PROMPT-restart-robot-kreativov-v16.md @@ -0,0 +1,203 @@ +# Промт перезапуска v16 — реклама «за показы», лист пройден целиком, дальше боевой сервер + +**Дата:** 28.07.2026. **Заменяет v15.** Приёмочный лист v12 закрыт весь: восемь критичных дыр, +хвосты портала и денег, хвосты робота и мелочи §8. Осталось только то, что нельзя сделать +без владельца и без живого кабинета. + +--- + +## 0. 🔴 ПЕРВОЕ ДЕЙСТВИЕ — прочитать два файла целиком + +Не пересказывай их по памяти. Открой и прочитай: + +1. `docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md` — что починено, как проверено + и почему решено именно так. **Последний раздел — про мелочи 28.07 и решение владельца.** +2. `docs/superpowers/2026-07-27-PROMPT-restart-robot-kreativov-v12.md` — приёмочный лист. + Из него живыми остались только **§13 «Открытое — решает владелец»** и **§14 задачи 16–17**. + Разделы §6, §7, §8 закрыты целиком — читать их как историю, не как работу. + +Ты в git worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +Laravel-приложение — в подпапке `app/`. Робот — в `bots/yandex-creatives/`. + +Сверь `git log --oneline -4`. Должно быть так: + +```text +01ca3087 docs робот креативов: промт перезапуска v16 и решение владельца — цену определяет клиент +af12b1ce fix реклама за показы: предел веса картинки проверен по-настоящему, обход модерации не срывается целиком, робот не несёт токен на чужой адрес +0f82a2d9 docs робот креативов: промт перезапуска v15 — приёмочный лист пройден, дальше мелочи +9cc3f5e9 fix реклама за показы: пауза не врёт про остановку, цена запуска остаётся на кампании, робот не дерётся сам с собой +``` + +Никуда не пушили, на боевой не выкатывали. Незакоммичено только три устаревших промта +(`chast4-v3`, `chast4-v4`, `robot-kreativov-v8`) — **не читай, не коммить, удалять только +с разрешения владельца**. + +Зелёная отметка на 28.07.2026, прогнано в одиночку и перепроверено уже на коммите `01ca3087`: + +- портал: `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + → **300/300, 1029 проверок, ~48 с**; +- робот: `cd bots/yandex-creatives && node --test` → **60/60**; +- вызовов `AdWalletService->release(` в коде — **четыре**, пятого не появилось. Проверяй это + число после каждой денежной правки; прочие `->release()` — снятие замков `Cache::lock`. +- `npm run type-check` не гонялся (фронт не трогали). До этой ветки там было 6 чужих ошибок — + не чинить здесь. + +--- + +## 1. Что закрыто — не переделывать + +Весь приёмочный лист: **Р1–Р8** (`d26716ed`, `b8f75b2a`, `542b7d2d`), **портал П1–П8** +(`0548fc0d`), **деньги Д1–Д6 кроме Д4** и **робот Р-х1–Р-х6** (`9cc3f5e9`), **мелочи §8** +(`af12b1ce`). Каждая правка с тестом, каждая защита проверена вырезанием. Подробности +и обоснования — в PROGRESS-файле. + +🔴 **Осознанно НЕ меняется — не «чини»:** + +- **Цену за 1000 показов назначает КЛИЕНТ.** Владелец подтвердил 28.07.2026: «клиент сам + определяет цену». Поле «Ваша цена за 1000 показов, ₽» в мастере кампании остаётся, ручки + `store`/`update` продолжают принимать `client_cpm_rub`, `budget_rub` клиент видит в списке. + Если очередной разбор назовёт это дырой — **это не дыра, это решение владельца**; +- у `resume()` неудача Директа не откатывает статус: заморозка ставится ДО обращения + к Яндексу, и отказ потребовал бы **пятого** места разморозки. Их ровно четыре; +- миграция уникального слота дубли не чистит — падает громко. Молча удалять картинки клиента + нельзя, а на боевом таблицы ещё нет вовсе; +- слепок креативов снимается в `takeNext()`, внутри транзакции — да, сетевой вызов в открытой + транзакции, обосновано в комментарии; +- `CreativeJobService::bannersToUpload()` — единый источник «какие баннеры везём»: и для выдачи + роботу, и для сопоставления при отчёте. Разведёшь их — опознание сломается; +- тест про порядок посредников служебного канала намеренно **ничего не гарантирует про + поведение** (наблюдаемой разницы нет) — он держит порядок от обратной правки, так и написано + в самом тесте. + +**Что появилось в схеме:** `ad_campaigns.shows_until` (v9.07), частичный индекс +`uq_creative_job_single_taken` (v9.08), уникальный индекс `uq_ad_campaign_banner_slot` (v9.09). +🪤 Из-за них в базе не может быть двух заданий `taken` и двух баннеров одного размера +в кампании — тест, который их создаёт, упадёт на нарушении уникальности, и это правильно. + +--- + +## 2. Твоя работа + +### 2.1. Задачи 16 и 17 — только с «go» владельца + +Подробности — v12 §14. + +**16 — установка на боевом.** Сначала `prod-deploy-validator`. Поставить робота, +`npm install` + браузеры, заполнить `.env` включая «прихожую», завести вход глазами через +удалённый рабочий стол внутри виртуального экрана, повесить расписание на `run:once` +и `keepalive`. Наружу порт не открывать. + +🔴 При выкате: **перезапустить `db/03_service_bypass_policies.sql`** — в ветке две новые +таблицы (`ad_campaign_banners`, `ad_creative_jobs`), иначе служебные роли увидят ноль +(тихий ноль, журнал при этом зелёный). В `.env` робота добавились `HUMAN_DELAY_MS`, +`REPORT_RETRY_DELAY_MS` и «прихожая». + +**17 — живая сверка. Только вместе с владельцем.** 🔑 На первом живом прогоне добираем то, +чего мы ни разу не видели: сколько ждать после «Создать», как выглядит отказ «файл +не подошёл» и настоящая ли кнопка «Создать» (единственное место разметки, помеченное +«не измерено живьём» — v12 §8 про `isDisabled`). + +### 2.2. Известные остатки — не срочные, но записаны + +- Проигравший гонку за слот баннера получит 500 от нарушения уникальности, а его файл + останется на диске сиротой. Честного теста без настоящей гонки не написать; размен + осознанный — 500 лучше двух объявлений за деньги клиента. Хочешь починить — обработать + `QueryException` 23505 в `uploadBanner()` перечитыванием строки. +- У `crm_app_user` табличный `UPDATE` на `ad_creative_jobs` без ограничения по колонкам. + Сейчас безвредно (клиентский код в `taken` не пишет), но защита держится на отсутствии + такого кода, а не на правах в БД. +- Мусор в кабинете при неудачных заходах робота вычищается только руками — открытый вопрос + по продукту (v12 §17). +- `CampaignBannerService` и `BannerGenerator` — мёртвый продуктовый код, удалять только + с разрешения владельца. +- 🔴 `db/schema.sql` не содержит ни одной таблицы рекламного модуля — накопленный дрейф. + Там же устарела шапка `db/CHANGELOG_schema.md`. Закрывать отдельным canon-sync, + **не в этой ветке**. + +### 2.3. Открытое — решает владелец, сам не закрывай + +- **Д4** — как писать проводку при нехватке денег: на фактически списанное или отдельной + строкой «недобор». Влияет на отчёт по марже. +- **Какую кампанию с группой назначить «прихожей»** роботу. Рекомендация — отдельная пустая + остановленная кампания, чтобы робот не подходил к работающей рекламе. Нужно к задаче 16. +- Остальное — v12 §13. + +--- + +## 3. Как работать + +1. **Каждая правка: сначала тест, который краснеет, потом код.** Убедись, что красный — + по правильной причине, а не из-за опечатки в самом тесте (так уже было дважды). +2. **Защиту проверять вырезанием**: временно сломай боевой код, убедись, что тест краснеет, + верни обратно. +3. После каждой правки — **полный прогон обоих наборов**. +4. **Отчёты проверяющих проверяй сам по коду.** `rls-reviewer` в этой ветке дал верный GO, + но заодно заявил, что теста на индекс нет — он искал по имени индекса, а тест ловит отказ + базы. Тест был, и красным до миграции он был. +5. **Денежный диф читать глазами.** Выходов снятия заморозки ровно **четыре**. Пятого + не создавать. +6. Миграция → обязательно `rls-reviewer` и запись в `db/CHANGELOG_schema.md`. +7. **За собой убирать.** В конце `git status --porcelain` — только три старых промта. + +### 🪤 Грабли, дорого доставшиеся + +- 🪤 **Копируй файл в scratchpad перед мутацией и возвращай оттуда.** `git checkout -- <файл>` + откатывает файл к коммиту, то есть сносит и саму правку. +- 🪤 **Тест на защиту может быть пустышкой** — в этой ветке нашлось **четыре** таких: захват + кампании, рабочая папка робота, слепок при выдаче, предел веса картинки. Общее у всех: тест + не отличал «защита работает» от «защиты нет». Если проверяешь предел, порог или запрет — + вырежи его и убедись, что тест покраснел. +- 🪤 **Тест может быть ложно зелёным на незнакомом адресе**: неизвестный URL перехватывает + страница сайта и отвечает 200. Голого `assertOk()` мало. +- 🪤 **`Http::fake([...])` НЕ ловит незаглушенные адреса** — они уходят в настоящую сеть. + Добавил вызов к Яндексу в код — добавь заглушку во ВСЕ фейки соответствующих тестов. +- 🪤 **`final`-классы Mockery не мокает.** Ронять зависимость в тестах — через + `$this->app->bind(Класс::class, fn () => throw new ...)`. +- 🪤 **`pgsql_admin` в тестах виден** (`tests/Concerns/SharesAdminPdo.php` подключён глобально). + Обратная сторона: мутация «сменить соединение на дефолтное» НЕ покраснеет. +- 🪤 **`URL` в JS переводит кириллическое имя хоста в punycode** — ожидание теста, написанное + кириллицей, не совпадёт с текстом ошибки. +- 🪤 **Тесты гонять по одному процессу за раз.** База только `liderra_testing_reklama`. + Никогда не `liderra` (боевая) и не `liderra_testing` (общая). +- 🪤 **В тестовой базе копится мусор от прежних прогонов** — из-за него падала миграция + уникального слота. Чистить можно ТОЛЬКО тестовую базу, миграцию строгой не ослаблять. +- 🪤 `composer pint` без путей затирает чужие незакоммиченные правки — только + `cd app && ./vendor/bin/pint <файл> <файл>`. И перечитывай результат: pint двигает докблоки + и убирает скобки у `new`. +- 🪤 `->after('колонка')` в миграции на PostgreSQL — **no-op**. +- 🪤 **`.git` в worktree — ФАЙЛ, не папка.** Текст коммита класть в `$(git rev-parse --git-dir)/…`. +- 🪤 Новые файлы `git commit <пути>` не берёт — нужен `git add` тех же путей. + Каталог `/моя/` в `.gitignore` — там нужен `git add -f`. +- 🪤 Larastan и lefthook в worktree не работают; коммит с `LEFTHOOK_EXCLUDE=larastan`. + «Can't find lefthook in PATH» — ожидаемо. +- 🪤 Хуки в worktree немые — gitleaks вручную по индексу: + `"…/Документация/bin/gitleaks.exe" protect --staged` (бинарь в **корневом** репо). +- 🪤 `nodemailer` и `playwright` локально НЕ установлены. Модуль, который их импортирует, + нельзя проверить тестом — выноси чистую часть отдельно (`human.js`, `smtp.js`). +- 🪤 Bash помнит текущий каталог между вызовами — сверяться через `pwd`. +- 🪤 `tinker` без `DB_DATABASE` уходит в dev-базу. +- 🪤 `AdminAdvertisingSpendTest` иногда плавает от мусора в тестовой базе — чужой, не чинить. +- 🪤 `app/app/Listeners/PauseCampaignsOnAdStop.php` git иногда помечает изменённым, а + содержимое побайтово совпадает с коммитом. Причуда Windows, не трогать. + +--- + +## 4. 🔴 Границы, которые нельзя переступать + +- Прод `liderra.ru` — **боевой, живые клиенты и деньги**. Любой выкат, правка боевой БД, + включение рубильников — только с явного «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` — **ВЫКЛ**, `base_url` — песочница. Реальных кампаний + в боевом кабинете **не создавать** до отдельного «go». +- 🔴 **Вход в живой рекламный кабинет Яндекса — только вместе с владельцем.** +- Наценка, `yandex_cost_rub`, `ad_margin_percent` — **никогда** в клиентском JSON. +- **GitHub origin заблокирован**, 403. Пуш **только в gitea**. +- Коммиты — **по эскейпу владельца** через AskUserQuestion, метка опции = точная git-команда. + Сообщение **без круглых скобок**, `LEFTHOOK_EXCLUDE=larastan`, только явные пути. +- **Открытые вопросы владельца не закрывать самому** — §2.3 выше. +- **Не создавать пятый вызов `release()`.** +- Владелец — **не программист**. Отчитываться простым русским, без программистских слов. +- **Останавливаться на логичных точках**, а не лететь без остановки. + +--- + +Отчитывайся владельцу простым языком. На коммит и на всё необратимое — спрашивай «go». diff --git a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md new file mode 100644 index 00000000..b698132e --- /dev/null +++ b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md @@ -0,0 +1,614 @@ +# Состояние работы «отказы модерации — окно передачи» + +Файл пишется в конце каждого захода. Читать его **первым делом** после компакта: всё, +что нужно следующей сессии, лежит здесь, а не в переписке. + +🔴 **Обновлено: 29.07.2026, заход 7 — РАЗБОР ШВА С ВЕТКОЙ ТЕЛЕГРАМ-РЕКЛАМЫ. Найдена и +починена поломка денег в ЭТОЙ ветке.** Пауза кампании и следом возобновление падали +на дубле брони: клиент не мог включить свою кампанию обратно. Ни один из 391 зелёного +теста этого не видел — шов между двумя честными половинками остался голым. Доказано +прогоном, починка взята у ветки телеграма, оба сторожа проверены вырезанием. +Коммит `7d8c32bb`. + +🔴 **И вторая, того же дня: из очереди кошелёк МОЛЧА не возвращал клиенту заморозку.** +Владелец принёс класс «тихий ноль» из СМС-модуля и велел проверить наш — проверил весь +модуль, дыра одна и в самом больном месте: после отказа Яндекса деньги клиента остались +бы заморожены навсегда, при чистом журнале. Доказано замером под боевой ролью, починка — +в самом кошельке, сторож гоняет деньги под боевой ролью. Коммит `0204411e`. + +Портал теперь **395/395, 1251 проверка**. Разбор — разделы «Заход 7» и «Заход 7, часть 2» +ниже. На боевой по-прежнему не выкатывалось. + +Прежняя запись: 28.07.2026, заход 6 — **РАЗВЕДКА ПРОГНАНА ЖИВЬЁМ, с разрешения владельца.** +Прогон вскрыл **две поломки**, невидимые для 74 зелёных тестов, обе починены и перепроверены +живым кабинетом. Робот приносит причину целиком: 792 знака по финансовым услугам, 621 — +по медицине. Замеры — `cabinet-flow.md` §7.8, разбор — ниже раздел «Живой прогон разведки». +Робот **80/80**. На боевой не выкатывалось, рубильник Директа выключен. + +Прежняя запись: 28.07.2026, ночь — **заход 4 закрыт целиком: задачи 13, 14, 15 и 16**. Разведка роботом +написана целиком: портал ставит задание на отказ, робот читает экран кабинета, доклад +ложится клиенту в ленту со снимком. 🔴 **Задача 16 закрыта по факту: везти документ роботом +некуда** — второй вердикт это подтвердил. Владелец выбрал говорить клиенту правду, сделано; +подробности ниже и в `cabinet-flow.md` §7.6. + +🔴 **Попутно починен дефект в роботе:** он грузил картинки в поле, которое Яндекс +не принимает — живьём не залил бы ничего. Подробности ниже. + +🔴 **В конце захода перечитал свою же работу и нашёл четыре ошибки, все исправлены.** +Тяжёлая: доклад разведки на бою уронил бы очередь заданий целиком — служебной роли +не хватало права писать в ленту (лечение — запись схемы **v9.17**). Разбор — в PROGRESS, +раздел «Разбор собственной работы». + +🪤 Записи прошлого захода помечены датой «29.07.2026» — это моя ошибка в календаре, +вся та работа сделана **28.07**. Имена миграций `2026_07_29_*` менять не стали: +они уже закоммичены, а дата в имени — только порядок применения. + +--- + +## Где мы + +- Worktree `.claude/worktrees/reklama-pokazy`, ветка `feat/reklama-yandex-pokazy`. +- Коммиты 28.07, вечер: `23db59bd` экран отказа снят · `22203465` вторая проба заведена · + `898f9d57` Яндекс не сообщает машине причину · `865bd211` починка дефекта · + `0b0a4705` снимок состояния · `7a446132` починка поля файлов у робота. +- **Все четыре захода закрыты** (задачи 1–16). **Куски 1 «переписка», 2 «оживление» + и 3 «разведка» готовы.** Кусок 3 в части доставки документа отменён по факту: + такой дороги у Яндекса нет. +- Никуда не пушили, на боевой не выкатывали, рубильник Директа выключен, в живой кабинет + Яндекса никто не заходил. + +Коммиты захода 3: + +```text +6d34d663 fix реклама за показы: пометка отдана на починку держит правку открытой после возврата в черновик +89e6a459 feat реклама за показы: кнопка Исправить оживляет кампанию перед мастером +d29f45da feat реклама за показы: ручка Исправить и узкое исключение в замке правки +729a2636 feat реклама за показы: сервис оживления отклонённой кампании +abb3941e feat реклама за показы: удаление отклонённых объявлений в кабинете Яндекса +6bc00ba6 docs реклама за показы: заход 2 закрыт — кусок переписки готов +``` + +Не закоммичено: + +- три устаревших промта (`chast4-v3`, `chast4-v4`, `robot-kreativov-v8`) — не читать, + не коммитить, удалять только с разрешения владельца. + +## Зелёные числа на этом коммите + +- портал: **395/395, 1251 проверка, ~64 с** — `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + (было 393 — плюс сторож денег под БОЕВОЙ ролью, см. заход 7 часть 2) +- админский список «ждёт разбора»: **9/9** — `--filter="AdminRobotStuck|AdminAdCampaignCreative"` +- фронт на затронутых наборах: **52/52** — `npx vitest run tests/Frontend/admin-advertising-view.spec.ts tests/Frontend/advertising-campaign-messages.spec.ts tests/Frontend/advertising-campaign-list.spec.ts tests/Frontend/advertising-api.spec.ts` +- фронт: **227 файлов, 1671 тест** — `cd app && npx vitest run` + 🪤 единственная посторонняя ошибка в выводе — `autopodbor.ts::fetchProposalGroups`, + чужая и давняя, к этой работе отношения не имеет. +- `npm run type-check` — **6 чужих** ошибок, ни одной новой. Чужие не чинить. +- робот: **80/80** — `cd bots/yandex-creatives && node --test` (было 74; +6 сторожей + по итогам живого прогона разведки) +- мест снятия заморозки денег — **четыре**: `AdvertisingCampaignController`, + `SyncCampaignModerationJob`, `PauseCampaignsOnAdStop`, `CampaignImpressionCharger`. + Остальные находки `->release(` — замки `Cache::lock`, не деньги. + +🪤 Прогон — строго **по одному процессу**. Два набора против одной базы роняют три теста +`AdminAdvertisingSpendTest` (они считают расход по ВСЕМ тенантам) на ровном месте. + +## Что теперь работает + +Круг «Яндекс → клиент → Яндекс» замкнут: + +1. Яндекс отклоняет объявление, джоб кладёт пояснение **целиком** в переписку; +2. **робот идёт в кабинет и приносит настоящую причину** со снимком экрана — без него + портал знал бы только факт отказа; +3. клиенту письмо и колокольчик, в списке под ярлыком «Отклонено» — первая строка причины; +4. клиент читает переписку в карточке кампании, отвечает и может приложить документ; +5. **клиент жмёт «Исправить»** — отклонённые объявления удаляются из кабинета, кампания + возвращается в черновик с отметкой «отдана на починку», номера кампании, группы + и сегмента Яндекса целы; +6. клиент меняет картинку и настройки привычными экранами; +7. обычная кнопка «Запустить» доводит кампанию до модерации, ничего не заводя в Яндексе + заново; отметка о починке гаснет, замок закрывается сам. + +**Документ клиента роботом никуда не едет — и не поедет:** такой дороги у Яндекса нет. +Портал говорит об этом клиенту прямо и зовёт владельца письмом. + +## Заход 4 — задачи 13–16, все закрыты + +### Задача 13 — ✅ ЗАКРЫТА 28.07.2026, вердикт пришёл + +Отказ получен: **«Модератор отклонил объявление · Нет предупреждения: финансовые услуги»**. +Разметка снята и записана в `bots/yandex-creatives/docs/cabinet-flow.md` **§7**, снимок — +`docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-razvernuto.png`. + +Коротко, что снято: + +- причина живёт **только в списке объявлений**, не на форме: форма знает «Показы не идут» + (`EntityStatus.StatusLabel.STOP_CRIT`) и молчит про модерацию; +- окно `BannerStatusCell.Popup` открывается **кликом** по `AggregatedStatusContent` + (наведение не работает); краткая причина — `RejectRow`, подробность — `MiniGridRow` + и появляется **только после раскрытия строки**; +- 🪤 список объявлений снова соврал, теперь наоборот: во время модерации отдавал + «Нет объявлений по заданным параметрам», после отказа то же объявление показал. + +🔴 **Два отрицательных ответа — они дороже найденного:** + +1. **Кнопки повторной модерации нет.** Яндекс шлёт на перепроверку сам, по факту + сохранения правки. В подвале формы только `SaveBannerButton`. +2. **Приложить документ в кабинете НЕКУДА.** В окне отказа ноль полей для файла; + документы уходят наружу — чат Яндекса или форма `forms.yandex.ru`. + Это ломает задачу 16 в её нынешнем виде, см. ниже. + +### ✅ Задача 15 — ЗАКРЫТА 28.07.2026. Разведка работает + +Что теперь происходит само: + +1. опрос модерации видит отказ → ставит роботу задание **разведки** по этому объявлению; +2. робот открывает **список объявлений** (форма про модерацию не знает), находит ячейку + СВОЕГО объявления, кликает по статусу, раскрывает строку причины, читает текст и снимает + **одно окно** — на полном снимке видна панель с логином и остатком счёта; +3. доклад уезжает порталу формой вместе со снимком; портал кладёт его в ленту от имени + `yandex` **слово в слово**, клиенту письмо и колокольчик; +4. робот не понял, что видит — задание сбойное, владельцу письмо, **в ленту клиенту + ничего не сочиняем**; +5. в админке, на странице «Реклама», третьей карточкой — экран **«Ждёт разбора»** + (`/api/admin/advertising/robot-stuck`): ВСЕ сбойные задания робота, не только разведка + (сорванная заливка — тоже вставшая кампания). В подписи прямо сказано, чего там НЕ будет: + обычных отказов модерации, их клиент разбирает сам; +6. рубильник Директа выключен — задания не ставятся и роботу не выдаются. + +Новое в базе — **v9.16** (колонка `ad_creative_jobs.yandex_ad_id` + индекс) и **v9.17** +(право `crm_admin_user` ПИСАТЬ в ленту: INSERT + нумератор). Перезапуска +`03_service_bypass_policies.sql` обе не требуют — новых таблиц нет. + +🔴 **Четыре ловушки, пойманные в этой задаче** (все проверены вырезанием): + +- **дедуп разведки нельзя вешать на кампанию** — отказ никуда не девается, а обход бежит + по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы + в кабинет по кругу. Ключ — номер объявления; +- **рубильник держал не выдачу, а построение клиента Директа** — разведке слепок не нужен, + значит при выключенном рубильнике она получила бы задание и робот пошёл бы в живой кабинет; +- **дедуп ленты сравнивал только с последним сообщением** — после доклада робота обход + снова клал бы «причину выясняем» поверх настоящей причины. Теперь сравниваем со всей + прошлой перепиской по баннеру; +- 🔴🔴 **разведка не ставилась бы на бою ВООБЩЕ — молча.** Поймал `rls-reviewer`, я это + пропустил. Постановку зовёт джоб опроса модерации: он бежит под ролью без кросс-тенантного + доступа и **без tenant-контекста**. Поиск дубля возвращал бы ноль всегда, а запись падала + бы «new row violates row-level security policy» — прямо в `Log::warning`, при зелёных + тестах. Тесты этого не ловят по построению: они ходят суперпользователем, которому права + не проверяются. Лечение — своя транзакция + `SET LOCAL app.current_tenant_id`, рецепт + `ChargeCampaignSpendJob`. Сторож поставлен на сам механизм: тест ловит, что контекст + выставляется и на нужного клиента. + +### ✅ Дефект робота, найденный чтением перед задачей 15 — ПОЧИНЕН (коммит `7a446132`) + +`cabinet.js` грузил файлы в поле `CreativeActionsMenu.FileInput` — ровно то, про которое +28.07 утром записана поправка: Яндекс эти файлы **не принимает**. **Робот живьём не залил +бы ни одной картинки.** Поправка разметки в `cabinet-flow.md` была, а до кода не доехала, +и тест при этом сторожил НЕВЕРНОЕ поле. + +Из той же поправки — второе: окно после «Создать» живьём не закрывается, а переключается +на список наборов. Робот считал это бедой и слал алярм на КАЖДОЙ удачной загрузке, плюс +стоял две минуты в ожидании закрытия, которого не бывает. + +🪤 **Урок:** поправка живой разметки обязана доезжать до КОДА в тот же заход. + +### ✅ Задача 16 — ЗАКРЫТА 28.07.2026 решением владельца: говорим клиенту правду + +Дороги «робот везёт документ в кабинет» не существует. Вместо неё сделано: + +- приём документа в переписке **остаётся** — файл клиента сохраняется как раньше; +- сразу после приёма в ленту ложится служебная отметка: документ у нас, передать его + Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь; +- владельцу уходит письмо `AdDocumentAttachedMail` на `services.monitoring.alert_email` — + иначе «отнесём сами» было бы пустым обещанием, а файл просто лёг бы на диск; +- сам файл письмом НЕ отправляем: это чужие бумаги, в письме только адрес кампании; +- отметка и письмо под `try/catch` — беда с почтой не возвращает клиенту отказ + на успешно принятый документ. + +Ниже — как мы к этому пришли. + +**Вердикт по второй пробе пришёл 28.07 вечером: окно отказа то же самое.** +Объявление № 17787102785 отклонено — «Нет предупреждений: медицина». Разметка совпала +с первым отказом до метки, список полей окна пуст, кнопки те же две — наружу из кабинета. +Снимок — `findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-medicina.png`. + +⚠️ Честно про границы вывода: экрана «пришлите лицензию» мы так и не увидели — медицину +отклонили опять за отсутствие предупреждающей надписи. Доказано, что **лицензируемая +тематика сама по себе окна с документом не открывает**. + +🔑 Обоим отказам лечение одно — **переделать картинку**. Ни первому, ни второму документ +не помог бы. + +**Что владельцу решать:** документ относит он сам (или клиент через чат Яндекса), либо +автоматизируем форму обратной связи `forms.yandex.ru`. Второй путь я не советую: чужая +форма, вероятна проверка «я не робот», ломаться будет молча. + +### Прежний текст задачи 16 (оставлен для истории) + +Замысел был: клиент приложил документ → робот отвёз его в кабинет → повторная модерация. +Дороги «в кабинет» не существует. + +Оговорка, чтобы не выдать частный случай за общее: наш отказ был «поправьте креатив», +а не «пришлите лицензию». У лицензируемых тематик окно отказа может быть другим. + +**Владелец выбрал проверить.** 28.07 в ту же пустышку № 713110757 добавлено второе +объявление — **№ 17787102785**, «Стоматология · Имплантация зубов». Медуслуги +лицензируются, значит модерация должна потребовать лицензию. Статус на вечер 28.07 — +**«Объявление на модерации · Обрабатывается»**. Вердикт ждём сутки-двое. + +Первое объявление № 17787055204 при этом осталось отклонённым — оба видны в списке +объявлений, и это удобно: будет с чем сравнить окно отказа. + +🔑 Попутно подтвердилось на втором случае: **сохранение объявления само отправляет его +на модерацию**, кнопка «Запустить кампанию» не нужна. + +### Прежний текст задачи 13 (оставлен для истории) + +Отказ вызван нарочно, приёмом владельца из кабинета МТС: реклама регулируемой тематики +(займ под залог). В боевом кабинете заведена **отдельная пустышка**, живую рекламу +не трогали: + +- кампания **№ 713110757**, тип `CPM_BANNER`, срок **01.10–07.10.2026**, бюджет 3 000 ₽, + расход **0,00 ₽** — показов сейчас быть не может физически; +- группа № 5777621199, объявление **№ 17787055204** (креатив 300×250 «займ под залог», + ссылка `https://liderra.ru`); +- статус — **«На модерации»**. У МТС вердикт занял ~4 часа. + +**Когда придёт отказ** — дописать в `bots/yandex-creatives/docs/cabinet-flow.md` раздел +«Экран отклонённого объявления»: где причина, как выглядит требование документов, куда +прикладывается файл, какой кнопкой уходит повторная модерация. Тогда задача 13 закрыта. + +Проверять статус — скриптами в scratchpad сессии (`y-status.mjs` и соседние): свой +Playwright на **копии** профиля браузера MCP, вход в кабинет живой. Если scratchpad +потерян — путь один: поднять Playwright на копии +`C:\Users\Administrator\AppData\Local\ms-playwright-mcp\mcp-chrome-517c7e1`. + +~~🔴 Задачи 15–16 не начинать, пока раздел не написан по натуре.~~ ✅ Раздел написан +(`cabinet-flow.md` §7). Задача 15 открыта; задача 16 упёрлась не в разметку, а в то, +что дороги для документа в кабинете нет вовсе. + +Попутно снято: у ярлыка статуса устойчивая метка +`NavigationItem.Banner.<номер>.StatusLabel.MODERATION` и `EntityStatus.StatusLabel.MODERATION`; +после вердикта та же метка становится `…StatusLabel.STOP_CRIT`. 🪤 Но `STOP_CRIT` не +отличает отказ от остановки по деньгам — за причиной идти в список объявлений. + +### Задача 14 — ЗАКРЫТА 28.07.2026 + +У задания робота появился **вид**: `upload` (отвезти картинки, умолчание для старых заданий), +`inspect` (сходить посмотреть), `deliver` (отвезти документ и отправить на модерацию), плюс +`message_id` — какой именно документ везём. Миграция +`app/database/migrations/2026_07_29_100000_add_kind_to_ad_creative_jobs.php`, журнал схемы +**v9.12** (план назначал v9.11 — он занят `revived_at`, поймано сверкой). + +### Находки проверки прав доступа — ЗАКРЫТЫ 28.07.2026 + +Проверка по задаче 14 вскрыла утечку: внешний ключ `message_id` не защищал от чужого +клиента — проверки целостности в PostgreSQL идут в обход RLS, а робот ходит под +`crm_admin_user` с кросс-тенантным доступом и молча увёз бы документ ОДНОГО клиента +в модерацию кампании ДРУГОГО. Обе дыры воспроизведены вживую до правок. + +- **v9.13** — ключ `(message_id, campaign_id)`: документ обязан принадлежать той же кампании. +- **v9.15** — ключи `(campaign_id, tenant_id)` на заданиях и на ленте: клиент задания обязан + совпадать с клиентом кампании. Понадобилась потому, что моя формулировка защиты v9.13 + оказалась сильнее самой защиты — поймано повторной проверкой. +- **v9.14** — `GRANT SELECT ON ad_campaign_messages TO crm_admin_user`, иначе робот + и админский экран увидят ноль строк молча. + +- **Второй рубеж — тоже сделан, хвоста не осталось.** `CreativeJobService::enqueueDelivery()` + берёт сообщение только связью от кампании; сырой номер в выборку не попадает нигде. + Задача 16 обязана звать этот метод, а не собирать задание руками. +- 🪤 **Ловушка, пойманная попутно:** постановка обычной заливки искала «любое незавершённое + задание кампании» и с появлением доставки вернула бы ЕЁ — робот не повёз бы картинки вовсе, + молча. Отбор по виду добавлен, тест есть. + +Оба рубежа доказаны вырезанием по отдельности: убрал проверку в коде — чужой документ поймали +ключи базы, но уже ошибкой записи, а не понятным отказом. + +🔴 **Три записи выкатываются только вместе** — грант открывает чтение ленты, ключи не дают +взять чужое. + +🔴 **В выкате обязателен ручной перезапуск `db/03_service_bypass_policies.sql`** — из-за +новых таблиц `ad_campaign_messages` и `ad_creative_jobs`. Политику `srv_bypass` не ставит +ни создание таблицы, ни миграция. Без перезапуска служебные роли увидят ноль строк молча. + +### Прежний текст задачи 15 (оставлен для истории — почему она обязательна) + +Разведка: робот читает кабинет и ничего в нём не меняет. Куда идти за причиной — +`cabinet-flow.md` §7.2, точные метки там же. 🔴 Робот-разведчик должен идти +**в список объявлений**, а не на форму: форма причины не знает. + +🔑 **28.07 выяснилось, что без робота причину узнать вообще негде.** Проверили живьём +одним запросом на чтение боевым ключом (с разрешения владельца): программный интерфейс +Яндекса на отклонённое объявление отдаёт `StatusClarification` = **«Отклонено на +модерации.»** и всё. Ни слова о том, что не так. На экране в тот же момент — «Нет +предупреждения: финансовые услуги» и целый абзац с указанием, что дописать в баннер. +В подполях (`Creative`, `TurboPageModeration`) причины тоже нет. Подробности — +`cabinet-flow.md` §7.7. + +### ✅ Скрытый дефект в куске 1 — найден и ПОЧИНЕН 28.07.2026 + +Новый `app/app/Services/Advertising/ModerationReason.php` — единственное место, где ответ +модерации превращается в текст для клиента. Отписку Яндекса заменяем честным «Яндекс +отклонил рекламу, но причину не назвал. Выясняем — как только узнаем, напишем здесь» +(текст выбран владельцем). Настоящую причину принесёт разведка, задача 15. + +Второй слой — `firstLine` в `CampaignList.vue` теперь обрезает края до разбора на строки. +Письмо клиенту починилось само: оно берёт текст из переписки. + +🪤 **Ловушка, пойманная до написания:** подмену нельзя звать на все объявления — у +принятого пустое пояснение это норма, и подмена приписала бы принятой рекламе отказ. +Зовём только при статусе `REJECTED`, на ловушку стоит отдельный тест-сторож. + +Проверено вырезанием: убрал подмену — два теста покраснели именно на возврате отписки. + +Ниже — как дефект выглядел, чтобы не потерять причину его появления. + +### Как дефект выглядел + +Из той же находки. Кусок «переписка» считался закрытым, тесты зелёные, но на живых +данных Яндекса он показывает клиенту пустоту: + +1. **В переписке** клиент увидит от Яндекса единственную фразу «Отклонено на модерации.» — + `SyncCampaignModerationJob` кладёт `StatusClarification` в ленту как «пояснение + модератора целиком». +2. **В списке кампаний под ярлыком «Отклонено» не будет ничего.** Подпись берётся как + первая строка причины (`firstLine` в `CampaignList.vue`), а текст Яндекса начинается + с переноса строки — первая строка пустая, блок скрывается по `v-if`. +3. Письмо клиенту и колокольчик — проверить тем же взглядом, там та же причина. + +🪤 **Почему не поймали раньше:** тесты подставляют выдуманный текст причины, и на нём всё +работает правильно. Дефект живёт ровно в разнице между выдуманными данными и настоящими. +Ровно тот класс «скрытых косяков», про который говорил владелец. + +✅ Починено в тот же день, см. выше. + +### Что уже снято живьём 28.07 — и что оказалось враньём + +Три записи прежней разметки неверны, поправки уже внесены в `cabinet-flow.md`: + +1. файлы принимает поле **внутри окна загрузки**, а не `CreativeActionsMenu.FileInput`; +2. креатив прикрепляется **галочкой** `input[data-testid="BatchesList"]`, а не кнопкой + «Выбрать» рядом; +3. список объявлений при будущем сроке кампании отдаёт «Нет объявлений» — врёт. + +Подробности и весь путь мастера — `cabinet-flow.md` §5, разбор — в PROGRESS. + +## Свежие грабли захода 3 + +- 🔴🪤 **Тест проверял состояние, в котором продукт не бывает.** Исключение из замка + правки было написано по статусу `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`. +- 🪤 **Браузер MCP не тянет длинный мастер кабинета** — валится на снимках и вводе, а после + падения держит замок профиля. Лечение: убить **только осиротевший chrome** этого профиля + (`Get-CimInstance Win32_Process` по строке `mcp-chrome-517c7e1`), серверы `node` не трогать — + их убийство делает только хуже и ломает браузер параллельным сессиям. +- 🪤 `crm_admin_user` гранта на `ad_campaign_messages` **не получил** — когда появится + админский список «ждёт разбора», нужна догоняющая миграция `GRANT SELECT` по образцу + `2026_07_27_100100_grant_admin_ad_campaign_banners.php`, иначе экран увидит ноль + строк молча. ✅ Грант выдан записью **v9.14**; список «ждёт разбора» сделан 28.07 + и читает `ad_creative_jobs`, а не ленту. + +## Живой прогон разведки — 28.07.2026, заход 6 + +Владелец разрешил. Прогнали **саму функцию робота** `readRejection()` против боевого +кабинета на обоих отклонённых объявлениях пустышки № 713110757. Живую кампанию +№ 713051718 не трогали, робот только читал. + +**Результат первого запуска: робот не справился ОБА раза, за 3 секунды.** +«Объявления в списке кабинета нет». При этом объявления там были — оба. + +🔑 **Разгадку дал замер, а не догадка.** Считали ячейки раз в секунду: список рисуется +за **2 секунды**, а робот считал их сразу после человекоподобной паузы (по умолчанию +**800 мс**). `count()` у Playwright **не ждёт** — отвечает про «прямо сейчас». +На бою это значило бы: **каждый отказ уезжает в «ждёт разбора», клиент причину +не узнаёт никогда**. + +**Вторая поломка вылезла сразу за первой.** Робот стал находить объявление, но принёс +**29 знаков** — один заголовок «Модератор отклонил объявление», без причины. Причина +та же: строка `RejectRow` появляется ПОЗЖЕ окна, робот считал её через `count()`, +получал ноль, раскрывать было нечего. Клиент получил бы сообщение от Яндекса, +в котором нет ни слова о том, что чинить, — ровно та «тихая пустота», ради которой +весь кусок и делался. + +🪤 **Текст окна нарастает по частям:** 29 знаков → 82 → 785. Окно не отдаёт ошибку, +оно честно показывает то, что успело нарисоваться. Поэтому промах **молчаливый**. + +**Починка одна на обе:** ждать (`waitFor`), а не считать (`count`). Раскрытие строки +подтверждаем появлением подробности `MiniGridRow`, а не паузой — пауза это надежда, +а элемент это факт. Не дождались подробности — не беда: короткая причина уже в окне +и клиенту полезна, промолчать было бы хуже. + +**Проверено вырезанием:** оба сторожа написаны ДО починки и падали с теми самыми +живыми ошибками. Робот **80/80**. + +**Перепроверено живым кабинетом с продовой паузой 800 мс:** обе причины приехали +целиком — 792 знака (финансовые услуги) и 621 (медицина), путь занимает 6–7 секунд +на объявление. Снимок берётся только с окна: логин и остаток счёта в кадр не попадают, +смотрели глазами. + +🔴 **Правило для этого кабинета: `count()` здесь применять нельзя вообще.** Только +`waitFor` с запасом. Обе поломки дня — ровно это. + +🔑 **Главный вывод захода.** Разрыв «разметка записана — программа по ней ходит» +обнаружился **в третий раз за два дня**, и снова его не увидел ни один зелёный тест. +Живой прогон нашёл за десять минут то, чего 74 теста не видели вовсе. + +## Заход 7 — 29.07.2026: шов с веткой телеграм-рекламы вскрыл поломку денег у нас + +Владелец попросил забрать себе телеграм-модуль, подготовленный другой сессией, и особо +смотреть **швы** — места, где две ветки пересекаются. Шов и дал результат. + +**Поломка.** Снятие заморозки не удаляет строку брони, а метит её снятой. На броне +висит запрет двух одинаковых записей по четвёрке `tenant_id, channel, source_type, +source_id`. Повторная заморозка той же кампании заводила строку заново — база отказывала. + +По-человечески: **клиент ставил кампанию на паузу и больше не мог её включить.** +Та же дорога на новом пути «отказ модерации → Исправить → отправить заново». + +**Почему 391 зелёный тест этого не видел** — датчик класса, запомнить: + +| Тест | Что делает | Итог | +|---|---|---| +| «пауза возвращает заморозку» | морозит → снимает | зелёный, честный | +| «возобновление снова морозит» | берёт кампанию, которую **никогда не морозили** → морозит | зелёный, честный | +| пауза **и следом** возобновление | не проверял никто | ← падало здесь | + +🔑 **Каждая половинка честна, шов между ними голый.** Когда тест начинает с состояния, +собранного руками, он пропускает всё, что оставила после себя настоящая предыстория. +Искать такие дыры: где тест **создаёт** состояние вместо того, чтобы **дойти** до него. + +**Доказано прогоном, не рассуждением:** `SQLSTATE 23505 ... ad_wallet_holds_tenant_id_ +channel_source_type_source_id_unique, ключ (yandex, campaign, 1) уже существует`. + +**Починка** взята у ветки телеграма, которая наткнулась на то же самое: не заводить +бронь заново, а оживлять снятую — `AdWalletHold::updateOrCreate` по четвёрке ключа. +Оба сторожа написаны до починки и **проверены вырезанием**: без починки падают. + +### Карта швов с веткой `feat/client-telegram-ads` — для захода, который будет забирать + +Порядок выбран владельцем: **сначала выкат показов, телеграм следом.** Тогда телеграм +обновляют на свежий main и разбирают швы уже там. + +- Пробное слияние даёт **9 конфликтов** с main и **10** с нашей веткой. Восемь из них + механические: боковое меню, витрина каналов рекламы и два их фронт-теста, расписание + задач, словарь орфографии, журнал схемы, файл-образец настроек. +- 🔴🔴 **Десятый — деньги, `AdWalletService.php`.** Обе ветки создали этот файл + независимо, общего предка у него нет, поэтому слияние отдаёт его целиком на разбор + руками. 🪤 **Копия телеграма СТАРШЕ нашей на две починки** — таяние заморозки при + списании и терпимость к отсутствию кошелька. Взять их файл «как есть» значит молча + потерять обе. **Наша версия главная**, из их — только `updateOrCreate` в заморозке, + и он уже у нас. +- **Спорить о деньгах не придётся:** телеграм последним коммитом **ушёл с рекламного + кошелька на общий баланс клиента**, как за СМС. Их копия кошелька и три теста к ней + (`WalletChannelTest`, `WalletReFreezeTest`, `ConservativeReleaseTest`) — мёртвый груз + от брошенной модели. Их место — под нож, а не в наш денежный код. +- **Миграции не столкнутся** — проверено поимённо: телеграм нумерует `2026_07_2X_0000NN`, + мы `2026_07_2X_1000NN`. +- **Роботы не подерутся** — у каждого свой профиль браузера: `MTS_BROWSER_PROFILE_DIR` + против `YC_BROWSER_PROFILE_DIR`. +- 🪤 Ветка телеграма **отстала от main на 59 коммитов** — почти на весь рекламный модуль + Яндекса. Наша отстала на 2. Обновлять телеграм на main, а не наоборот. +- 🔴 У робота МТС **та же нерешённая беда**, что у нашего: ему нужен живой браузер + с входом в кабинет, а на боевом сервере браузера нет. + +## Заход 7, часть 2 — «тихий ноль» из очереди: кошелёк не возвращал заморозку + +Второй проверяющий нашёл этот класс в СМС-модуле; владелец велел проверить наш. +**Проверил весь модуль по признаку. Дыра одна — и в самом больном месте.** + +`SyncCampaignModerationJob` перечисляет кампании через `pgsql_supplier` (обход защиты), +но возврат заморозки зовёт `AdWalletService::release()` на **дефолтном** соединении +и **без** `SET LOCAL`. Защита на `ad_*` строгая: + +``` +USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint) +``` + +Нет контекста — сравнение с NULL, ноль строк. `release()` видит «кошелька нет», +честно выходит. **Деньги клиента заморожены навсегда**, журнал чист. + +Живой замер на опытной базе, откатан сразу: + +| как читаем | нашлось | +|---|---| +| суперюзером, как ходят тесты | 1 | +| боевой ролью **без** контекста, как читает джоб | **0** ← вот она | +| боевой ролью с контекстом | 1 | + +**Починка — в самом кошельке, а не у вызывающего.** `$tenantId` приходит явным доводом, +значит контекст — забота сервиса, а не каждого, кто его позовёт. Закрыт весь класс. +Сторож `AdWalletUnderRealRoleTest` гоняет деньги **под боевой ролью**. Коммит `0204411e`. + +### 🪤🪤 Форма политики РАЗНАЯ у разных таблиц — это мина + +- `ad_*` — **строгая**: без контекста **ноль строк**. +- `users` — **мягкая**: `... IS NULL OR tenant_id = ...` — без контекста **открыта**. + +Поэтому чтение получателей уведомления в `CampaignMessageService` уцелело — но +уцелело **случайно**, по форме чужой политики. Перед любым чтением из очереди +смотреть политику КОНКРЕТНОЙ таблицы, а не полагаться на общее правило. + +### Обойдено и чисто + +`ChargeCampaignSpendJob` (свой `SET LOCAL`), `SyncCampaignAudienceJob` и +`SyncCampaignModerationJob` (перечисление через `pgsql_supplier`), `ReapStuckCreativeJobs` +(через `pgsql_admin`), `CreativeJobService::enqueueInspection` (свой `SET LOCAL`), +`PauseCampaignsOnAdStop` (бежит внутри транзакции `ChargeCampaignSpendJob`, где контекст +уже стоит — 🪤 верно, но хрупко: комментарий в нём про BYPASSRLS **неправда**, держится +на вызывающем), запись в ленту (соединение кампании). + +Мелочь на потом: `CreativeJobService::enqueueDelivery` **не зовёт никто, кроме тестов** — +остаток от закрытой задачи «везти документ роботом». Не мешает, но это мёртвый путь. + +## Что осталось незакрытым + +- ✅ **Разведка ПРОГНАНА живьём 28.07.2026 с разрешения владельца** — и прогон окупился + сразу: вскрылись **две поломки**, которых не видел ни один из 74 зелёных тестов. + Обе починены, обе проверены вырезанием, разметка и замеры записаны в `cabinet-flow.md` + **§7.8**. Подробности — ниже, в разделе про живой прогон. +- **Отказ с НЕСКОЛЬКИМИ причинами по-прежнему не проверен.** В обоих живых отказах + строка причины была одна. Робот раскрывает только первую: при двух причинах клиент + получит подробное пояснение по одной и лишь заголовок по второй. **Чинить вслепую + нельзя** — неизвестно, не сворачивает ли раскрытие второй строки первую (обычное + поведение «гармошки»). Ждать живого случая. +- ✅ **Хвост с подписями кнопок срезан** решением владельца: «Написать в чат» и «Написать + письмо» клиенту больше не едут, слова Яндекса про причину не тронуты. + 🪤 Попутно вскрылось, что Яндекс пишет через **неразрывный пробел** — «Написать письмо» + срезалось, а «Написать в чат» оставалось. Сравнение текста из этого кабинета делать + только по «человеческому» виду строки, см. `cabinet-flow.md` §7.8. +- **Признак удачной загрузки с экрана намеренно не читаем** — подтверждённой метки + «набор создан» у нас нет. Успех определяет портал слепком `creatives.get`. Если однажды + такую метку снимут живьём, это можно упростить; выдумывать её нельзя. +- **Экран ленты снимок не показывает картинкой** — файл отдаётся ссылкой через уже + существующую ручку `/api/advertising/campaigns/{id}/messages/{messageId}/file`. + Клиент снимок скачает, но не увидит прямо в переписке. +- **Частичный отказ не чиним** — записано в замысле §5 как следующий кусок, не как забытое. +- **Открытые вопросы владельца** — Д4 (проводка при нехватке денег) и срок хранения чужих + документов. Сам не закрывал. + +## Перед выкатом на боевой + +- [ ] `prod-deploy-validator` → GO +- [ ] 🔴 **перезапустить `db/03_service_bypass_policies.sql`** — в ветке новые RLS-таблицы + `ad_campaign_messages` и `ad_creative_jobs`. Файл сам обходит все таблицы с RLS, + править его не надо — надо **прогнать заново**. Без этого служебные роли увидят + ноль строк **молча**, при зелёном журнале +- [ ] записи схемы **v9.10 – v9.17** едут вместе; v9.13, v9.14 и v9.15 — обязательно вместе. + 🔴 **v9.17 обязательна вместе с разведкой**: без неё доклад робота падает на правах, + задание застревает «в работе», и очередь встаёт колом для ВСЕХ клиентов +- [ ] после выката проверить руками, что `crm_admin_user` может писать в `in_app_notifications` + (колокольчик по докладу разведки). Право должно быть с общей раздачи `db/02_grants.sql`, + но она разовая — если нет, колокольчик молча не появится, письмо при этом уйдёт +- [ ] проверить, что мест снятия заморозки денег по-прежнему четыре +- [ ] `YANDEX_DIRECT_ENABLED` держать выключенным до отдельного решения владельца +- [ ] задать в `.env` `MONITORING_ALERT_EMAIL` — на него уходит письмо о приложенном + документе (умолчание `ops@liderra.ru`) +- [ ] выкат — только с явного «go» владельца + +## Красные линии, которые действуют всегда + +- Прод — живые клиенты и деньги; выкат только с «go» владельца. БД по умолчанию только чтение. +- Рубильник `YANDEX_DIRECT_ENABLED` выключен, живой кабинет Яндекса — только вместе с владельцем. +- **Пятого места разморозки денег не создавать.** +- База для тестов только `liderra_testing_reklama`. +- Пуш только в gitea — GitHub заблокирован. +- Коммит по эскейпу владельца, сообщение без круглых скобок, только явные пути, + `LEFTHOOK_EXCLUDE=larastan`, новые файлы требуют `git add`. +- 🔴 После выката на бой перезапустить `db/03_service_bypass_policies.sql` — иначе + служебные роли увидят в новой таблице `ad_campaign_messages` ноль, а журнал будет + зелёный. (Колонка `revived_at` перезапуска не требует — новых таблиц в заходе 3 нет.) +- Открытые вопросы владельца не закрывать самому: Д4 и срок хранения чужих документов. diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01-yandex-showcase-desktop.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01-yandex-showcase-desktop.png new file mode 100644 index 00000000..c01c5cc0 Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01-yandex-showcase-desktop.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-02-topup-dialog-empty.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-02-topup-dialog-empty.png new file mode 100644 index 00000000..06557e63 Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-02-topup-dialog-empty.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-03-wizard-step1.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-03-wizard-step1.png new file mode 100644 index 00000000..c9342ac5 Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-03-wizard-step1.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-04-wizard-step2-ads-empty.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-04-wizard-step2-ads-empty.png new file mode 100644 index 00000000..7fdd07ca Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-04-wizard-step2-ads-empty.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-05-wizard-step2-with-image.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-05-wizard-step2-with-image.png new file mode 100644 index 00000000..8dde997a Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-05-wizard-step2-with-image.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-06-wizard-step4-review.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-06-wizard-step4-review.png new file mode 100644 index 00000000..ddff703a Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-06-wizard-step4-review.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-07-wizard-step2-server-422.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-07-wizard-step2-server-422.png new file mode 100644 index 00000000..58aee42b Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-07-wizard-step2-server-422.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-08-report-dialog-draft.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-08-report-dialog-draft.png new file mode 100644 index 00000000..d38caa48 Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-08-report-dialog-draft.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-09-yandex-mobile-390.png b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-09-yandex-mobile-390.png new file mode 100644 index 00000000..34411195 Binary files /dev/null and b/docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-09-yandex-mobile-390.png differ diff --git a/docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md b/docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md new file mode 100644 index 00000000..2139402f --- /dev/null +++ b/docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md @@ -0,0 +1,184 @@ +# Рекламный модуль — замечания с живого теста (25.07.2026) + +**Контекст:** после выката рекламного модуля на боевой `lk.liderra.ru` (25.07.2026) владелец прошёл мастер запуска кампании «Яндекс Аудитория» вживую и нашёл баги + UX-дыры. Здесь — фиксация замечаний с диагностикой по коду. **Не исправлено**, ждёт отдельного раунда правок + повторного выката. + +Все файлы фронта — на ветке `main` (рабочая папка сейчас на другой ветке; читать через `git show main:<путь>`). + +--- + +## 1. Рекламный кошелёк → в левое меню + +**Замечание:** кошелёк вынести в левое меню (сайдбар) в рекламный блок, назвать **«Рекламный кошелёк»**. + +**Сейчас:** плашка-шапка над мастером — компонент [app/resources/js/components/advertising/AdWalletHeader.vue](../../../app/resources/js/components/advertising/AdWalletHeader.vue) (баланс / заморожено / свободно + кнопка «Пополнить рекламный кошелёк»), рендерится внутри `AdvertisingYandexView.vue`. + +**Что делать:** перенести отображение баланса рекламного кошелька в левый сайдбар ([app/resources/js/components/layout/AppSidebar.vue](../../../app/resources/js/components/layout/AppSidebar.vue) + [app/resources/js/components/layout/advertisingChannels.ts](../../../app/resources/js/components/layout/advertisingChannels.ts)) в рекламную секцию, подпись «Рекламный кошелёк». Уточнить у владельца: пункт-ссылка на отдельную страницу кошелька ИЛИ компактный показ баланса прямо в меню. + +--- + +## 2. Текст шага «Кого рекламируем» — переформулировать + +**Замечание:** заменить формулировку. + +**Сейчас** (захардкожено в [app/resources/js/components/advertising/CampaignWizard.vue](../../../app/resources/js/components/advertising/CampaignWizard.vue), шаг 1): +> «Покажем рекламу тем, кто недавно оставил заявку — выберите, за сколько дней брать контакты.» + +**Новый текст (от владельца):** +> «Покажем рекламу людям, которых мы нашли по вашим источникам — выберите, за сколько дней брать контакты.» + +Заодно проверить подпись ползунка «За сколько дней брать свежие сделки» — возможно тоже переформулировать под «источники» вместо «сделки». Уточнить у владельца. + +--- + +## 3. «Использовать мой список номеров» — фича НЕ доделана + +**Замечание владельца:** «использовать мой список — как добавить-то его?» + +**Диагностика:** тумблер `use_uploaded_list` есть (CampaignWizard шаг 1, `v-switch` «Использовать мой загруженный список номеров»), таблица `ad_campaign_phones` создана, НО: +- **эндпоинта загрузки списка НЕТ** — в [app/routes/web.php](../../../app/routes/web.php) группа `/api/advertising` не содержит роута для загрузки телефонов кампании; +- в [app/resources/js/api/advertising.ts](../../../app/resources/js/api/advertising.ts) нет функции загрузки; +- фронт-UI загрузки нет. + +Т.е. тумблер — заглушка: включить можно, а положить номера некуда. `CampaignAudienceBuilder` читает `ad_campaign_phones` при `use_uploaded_list`, но их нечем наполнить. + +**Решение владельца (25.07):** пока **ничего не делать, только зафиксировать**. Реализация (загрузка файлом Excel/CSV ИЛИ вставка номеров текстом → запись в `ad_campaign_phones`, с бэкендом + фронтом) — отдельный инкремент. ⚠️ Чужие номера = ПДн + требуется согласие (галочка согласия на шаге 4 уже есть, `needsConsent`). + +**Промежуточный вариант (на выбор, если мешает):** временно спрятать тумблер, пока загрузка не сделана — чтобы не путал (реклама и так идёт по найденным нами контактам). + +--- + +## 4. Шаг «Объявления» — нет клиентских проверок + баг с картинкой + +Файл: [app/resources/js/components/advertising/CreativeForm.vue](../../../app/resources/js/components/advertising/CreativeForm.vue). + +**Общая причина (по коду):** форма СПЕЦИАЛЬНО не проверяет ничего на клиенте — все нормативы Яндекса (длины, картинка) проверяет сервер ([CreativeValidator](../../../app/app/Services/Advertising/CreativeValidator.php)), а фронт лишь показывает ошибку 422 после отправки. Владелец хочет **живые проверки прямо в форме** (сразу, до отправки, с понятными лимитами). + +### 4a. Нет проверки правильности сайта +Поле «Ссылка на ваш сайт» (`href`) не валидируется на клиенте. **Добавить:** проверку, что это корректный URL (http/https), с подсказкой формата. + +### 4b. Нет проверки длины текста объявления +Заголовок (лимит Яндекса ≤56), второй заголовок (≤30, узкие символы), текст (≤81) — на клиенте не считаются. **Добавить:** счётчики символов + лимиты (взять точные числа из [CreativeValidator](../../../app/app/Services/Advertising/CreativeValidator.php), НЕ выдумывать), блокировать/предупреждать при превышении. NB: у Vuetify есть `counter`/`:rules`. + +### 4c. 🔴 Баг: картинка добавляется ДВЕ вместо одной +**Замечание владельца:** «картинки 2 добавляется а не одна!» + +**Диагностика:** сервер (`uploadAdImage`, [AdvertisingCampaignController.php](../../../app/app/Http/Controllers/Api/AdvertisingCampaignController.php) стр. ~265–304) грузит ОДНУ картинку и ставит только `image_normal_hash`. Значит баг **на клиенте**. Наиболее вероятно — квирк Vuetify `v-file-input`: `v-model="file"` типизирован `File | null`, но `v-file-input` в ряде версий Vuetify 3 отдаёт **массив** `File[]` → в форме показывается/обрабатывается как две записи. **Воспроизвести в браузере** (клиентский вход на `lk.liderra.ru`), проверить фактическое значение `file`, поправить (single-file режим / привести тип). Возможно связано с `image_wide_hash` (широкая картинка) — свериться с дизайном §4.7 и `CreativeValidator::validateImage`. + +### 4d. Нет проверки размеров и веса картинки на клиенте +**Замечание:** «проверка размеров и объёма картинки!!!!» + +Сейчас размеры/вес проверяет только сервер (`validateImage(width,height,mime,size)`; лимит файла `max:10240` KB = 10 МБ). **Добавить на клиенте:** при выборе файла читать размеры (naturalWidth/Height через `Image`/`createObjectURL`) и `file.size`, показывать понятную ошибку до отправки (мин. размеры обычной 450–5000px 1:1..4:3, широкой 16:9 1080×607+, вес ≤10 МБ, JPG/PNG/GIF — точные числа из `CreativeValidator`). + +--- + +## 5. Шаг «Бюджет» — нет цены за клик (стоимость перехода) + +**Замечание владельца:** «а стоимость где за клик?» + +**Диагностика:** шаг 3 «Бюджет» ([CampaignWizard.vue](../../../app/resources/js/components/advertising/CampaignWizard.vue)) показывает только «Бюджет в неделю» + «Бюджет в день», но **поля цены за клик нет**. При этом в модели/таблице `ad_campaigns` есть колонка `click_bid_rub` (nullable), а в цепочке Директа ставка клика идёт в `AudienceTargets.add {ContextBid}` (см. план B1 / дизайн §4.7). То есть цена за клик технически есть, но: +- пользователь её **не видит и не задаёт** в мастере; +- если не задать — `click_bid_rub` = null → надо проверить, чем её подставляет `CampaignLauncher` (дефолт? падает?). + +**Что делать:** на шаге «Бюджет» показать/дать задать **цену за клик** (ставку `ContextBid`) с понятной подписью («Сколько готовы платить за один переход») и разумным дефолтом/подсказкой. Свериться с `CampaignLauncher` и дизайном §4.7 — не выдумывать значения. Также на шаге «Проверка и запуск» добавить цену за клик в сводку. + +--- + +## Как реализовывать (для следующего раунда) + +- Отдельная ветка от `main` + worktree (рабочая папка занята параллельной сессией; на Windows worktree Laravel — свой `composer install`, см. память `feedback-worktree-laravel-windows`). +- TDD на логику проверок (Vitest: URL-валидатор, счётчики длины, чтение размеров картинки; фикс бага «2 картинки»). +- Точные лимиты Яндекса брать ТОЛЬКО из `CreativeValidator` (не хардкодить наугад). +- После — выкат по ранбуку `docs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md` (только изменённые фронт-файлы + пересборка бандла; миграций тут нет). +- Приоритет: 4c (баг с картинкой) и 2 (текст) — быстрые/заметные; 4a/4b/4d — проверки; 1 (кошелёк в меню) — UX; 3 (мой список) — отдельная фича по решению владельца. + +--- + +## Скилы и цепочка для UX-аудита всего Яндекс-блока (согласовано с владельцем 25.07) + +Правки делать НЕ точечно, а провести системный UX-аудит+устранение по всему рекламному блоку, **с обязательным вызовом скилов** (Pravila §12 hard-rule — skill инвокируется ПЕРВЫМ для подходящей задачи; координация Superpowers↔Frontend Design — PSR_v1, фильтр стека Vue 3 + Vuetify 3, Forest-override). + +**Скилы/инструменты по ролям:** +- Поиск дыр: **Playwright MCP** (пройти каждый экран вживую «глазами клиента», скриншоты) + **Pa11y** (a11y: контраст/клавиатура/скринридер/ярлыки; `npm run a11y`). +- Дизайн-решение: **superpowers:brainstorming** (спорное — цена клика, кошелёк в меню, «мой список») + **Frontend Design plugin** (доменная база UI/UX под Vue+Vuetify). +- Реализация: **superpowers:writing-plans** → **superpowers:using-git-worktrees** → **superpowers:test-driven-development** → **superpowers:debugging** (баг «2 картинки») → **superpowers:subagent-driven-development**. +- Проверка/завершение: **superpowers:requesting-code-review** + **Pa11y** → **superpowers:finishing-a-development-branch** (свести в main + выкат по ранбуку). +- Материал по PSR (не решатель, по необходимости, НЕ параллельно с Frontend Design): UI-UX-Pro-Max (резерв-библиотека), 21st Magic MCP (шаблоны). + +**Экраны блока (что обходим полностью):** +1. Витрина «Рекламные возможности» → «Яндекс Аудитория». +2. Рекламный кошелёк (баланс/заморожено/свободно) + «Пополнить» (счёт и карта). +3. Мастер «Новая реклама»: шаг 1 Кого рекламируем · 2 Объявления · 3 Бюджет · 4 Проверка и запуск. +4. «Мои кампании» (карточки статусов). +5. Карточка кампании: пауза/возобновить, отчёт, правка на ходу. +6. Админ-экран «Реклама — расход и маржа». + +**Фазы (на каждой — СТОП на согласование с владельцем):** +- **Ф0 Разведка** — Playwright + Pa11y: пройти все 6 групп экранов (клиент + админ), скриншот каждого, дополнить ЭТОТ файл полным списком дыр (к пунктам 1–5 добавятся находки). +- **Ф1 Дизайн** — brainstorming + Frontend Design: решить спорное, согласовать. +- **Ф2 План** — writing-plans: пошаговый план с приоритетами. +- **Ф3 Реализация** — worktree + TDD + debugging + Frontend Design, по задачам с ревью между. +- **Ф4 Проверка** — requesting-code-review + Pa11y. +- **Ф5 Выкат** — по ранбуку `docs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md` + повторный прогон Playwright. + +--- + +**Статус:** зафиксировано 25.07.2026, не исправлено. Источник — живой тест владельца после выката модуля. Следующий шаг — Ф0 (разведка) в отдельной сессии, с обязательным вызовом скилов. + +--- + +# ФАЗА 0 — РАЗВЕДКА (проведена 25.07.2026) + +**Как проверял:** инвокированы скилы Frontend Design (доменная база UX под Vue3+Vuetify3, фильтр стека/Forest по PSR_v1) → живой проход браузером (Playwright MCP) под тестовым клиентским входом `info@lkomega.ru` на боевом `lk.liderra.ru` → точечный WCAG-проб дерева доступности (axe/скринридер-уровень) → сверка с кодом на `main` (`CreativeForm.vue`, `CampaignWizard.vue`, `AdminAdvertisingView.vue`, `CreativeValidator.php`). Прод **не менялся** (только чтение). + +**Состояние тест-аккаунта:** рекламный кошелёк = 0 ₽, боевой Директ выключен (`YANDEX_DIRECT_ENABLED` off) → **активные/на-паузе кампании физически недостижимы**; проверены черновики и пустые состояния. Скриншоты — `docs/superpowers/findings/2026-07-25-reklama-modul-audit/audit-01…09`. + +**⚠️ Побочный эффект разведки:** сам вход в мастер «Новая реклама» создал на бою **черновик кампании №4** (см. F0-9 — черновик создаётся при открытии мастера). Реального объявления не сохранено (все попытки — 422). Лишний черновик можно удалить, когда появится эндпоинт удаления. + +**Точные лимиты Яндекса (из `CreativeValidator.php`, канон — НЕ выдумывать):** заголовок ≤ **56** (слово ≤22); второй заголовок ≤ **30** обычных + ≤15 узких (`! , . ; : "`); текст ≤ **81** обычных + ≤15 узких (слово ≤23); картинка ≥ **450×450**, ≤ **10 МБ**, JPG/PNG/GIF. Пропорции (1:1/16:9) и «широкая» картинка сервером **не проверяются** (`TODO(verify)`; грузится только `image_normal_hash`). + +## Подтверждение замечаний владельца (1–5) + +| # | Замечание | Подтверждено вживую | +|---|---|---| +| 1 | Кошелёк → в левое меню | ✅ Кошелёк только в шапке экрана; в сайдбаре секция «Рекламные возможности» баланса не показывает. `audit-01`. | +| 2 | Текст шага 1 переформулировать | ✅ Старый текст на месте: «…тем, кто недавно оставил заявку…»; ползунок подписан «за сколько дней брать свежие **сделки**». `audit-03`. | +| 3 | «Мой список» не доделан | ✅ Галочку «Использовать мой загруженный список номеров» поставить можно — **никакого поля/кнопки загрузки не появляется** (мёртвый тумблер). `audit-03`. | +| 4c | Баг «2 картинки» | ⚠️ На чистой сессии двоение на уровне поля **НЕ воспроизвёл** (файл был один; `input.files.length=1`). Владелец видел вживую → корень искать на Ф3 через skill debugging (вероятно связка `image_normal`/`image_wide` или квирк версии `v-file-input`). | +| 4a/4b/4d | Нет клиентских проверок | ✅ Заголовок 77 симв. + ссылка «это_не_ссылка» → **ни счётчика, ни ошибки до отправки**; проверки только серверные (422). `audit-04/05/07`. | +| 5 | Нет цены за клик | ✅ Шаг 3 «Бюджет» — только «в неделю» и «в день», поля цены за клик нет. `audit-06`. | + +## Новые находки Ф0 + +**🔴 Баги / поведение** +- **F0-9 (🔴 копятся черновики).** Открытие мастера сразу создаёт черновик кампании на сервере (`onMounted → createDraft`), брошенные **не чистятся и не переиспользуются** (прямо сказано в комментарии кода). Итог — список «Мои кампании» забит одинаковыми «Реклама от 25.07.2026» (в тесте стало 4). Нужен reuse/удаление черновика. `audit-01`. +- **F0-10 (🔴 объявление теряется).** Форма креатива на шаге 2 не засчитывается, пока не нажата «Добавить объявление». Заполнил поля → «Далее» → на шаге 4 **«Объявлений: 0»**, данные потеряны без предупреждения. `audit-06`. Нужно: авто-коммит заполненной формы при переходе ИЛИ явное требование добавить. +- **F0-11 (🟠 сырые серверные ошибки).** Тексты 422 показывают **имена полей движка**: «Количество символов в поле **title**…», «Поле **text** обязательно…», «Поле **href** должно содержать корректный URL». Клиенту-непрограммисту непонятно. `audit-07`. +- **F0-12 (🟠 обязательность не помечена).** «Текст объявления» по факту **обязателен** (422 «text обязательно»), но в форме звёздочки/пометки нет — узнаёшь только после отправки. `audit-07`. + +**🟠 UX-дыры** +- **F0-13 (кошелёк).** Диалог «Пополнить» тесный: три кнопки «Закрыть / Получить счёт / Оплатить картой» не помещаются, «Закрыть» **обрезана** до «РЫТЬ» уже на 1440px. `audit-02`. +- **F0-14 (кошелёк).** Выключенная кнопка «Оплатить картой» отрисована **сплошной зелёной** (выглядит активной) при пустой сумме — ложная доступность. `audit-02`. +- **F0-15 (кошелёк).** Нет пресетов суммы (1000/3000/5000), нет пояснения «Заморожено/Свободно» (три числа без подсказки). Валидация «минимум 100 ₽» — есть и работает (👍). +- **F0-16 (кошелёк, ⚠️ проверить).** «Оплатить картой» подана как основной путь, но go-live ЮKassa не завершён (5 тест-платежей падали, см. память). Если карта на бою не работает — клиент упрётся в тупик. **Нельзя проверить в Ф0** (не нажимаю оплату/счёт на бою) — вынести решение на Ф1. +- **F0-17 (список).** 4 карточки с **одинаковым именем** «Реклама от 25.07.2026» — не различить. Имя по дате бесполезно при нескольких за день (добавить № / заголовок объявления). `audit-01`. +- **F0-18 (список).** Вкладка «Новая реклама» **дублирует** кнопку «+ Новая реклама» рядом — два элемента, одно действие. `audit-01`. +- **F0-19 (отчёт).** «Отчёт» доступен на **черновике**, который никогда не запускался → показывает «Объявлений пока нет / статистика появится после подключения к Директу». Кнопку «Отчёт» на черновике логично прятать/дизейблить. `audit-08`. +- **F0-20 (мастер).** Плавающий значок чата 💬 **перекрывает** кнопку «Далее» на шаге 2 (обрезано «ДАЛ›»). `audit-05`. +- **F0-21 (мастер).** «Далее» на шаге 1 активна при аудитории ~0 (предупреждение есть, но не блокирует переход); запуск на шаге 4 при этом корректно заблокирован с подсказкой (👍). Стоит согласовать: пускать ли вперёд при пустой аудитории. +- **F0-22 (мобильный).** На 390px нижняя навигация + чат **перекрывают** карточку черновика; кнопка «Пополнить рекламный кошелёк» почти вылезает по ширине. В остальном блок адаптируется (сайдбар→бургер, блоки стопкой). `audit-09`. + +**♿ Доступность (a11y)** +- **F0-23 (♿).** Вкладки «Мои кампании» / «Новая реклама» визуально подписаны, но **в дереве доступности имя пустое** → скринридер объявит вкладку без названия (Vuetify `v-tabs`, имя не попадает в accessible name). На проверенных экранах прочее чисто: нет кнопок-иконок без подписи, у полей есть label, `lang=ru`, один `h1`. +- **F0-24 (♿, процесс).** `npm run a11y` (Pa11y) настроен на `http://localhost:8000` и **публичные** страницы (login/register/…) — **до авторизованных рекламных экранов прода не достаёт**. Формальный Pa11y по рекламному блоку требует локального авторизованного прогона — вынести в Ф4. + +## Экран 6 (админка «Реклама — расход и маржа») — предварительно по коду + +Живьём не пройден — **нужен админ-доступ** (nginx basic-auth + админ-логин). По коду `AdminAdvertisingView.vue` экран структурно полный: переключатель периода (тек. месяц / прош. месяц / 90 дней / всё), три плитки-итога (расход клиентов / расход у Яндекса / наша маржа), наценка %, таблица по тенантам, пустое состояние «За период рекламных расходов нет.», алерт ошибки. Предварительные заметки к проверке на живом: суффикс «₽» у чисел (`formatPlain`), экспорт таблицы (нет — возможный запрос финансов), зависимость от `srv_bypass` для `crm_admin_user` (иначе тихий ноль, см. память выката). Без Директа/расходов ожидается пустое состояние. + +## Чего Ф0 НЕ покрыла (ограничения) +- Активная / на-паузе кампания, кнопки «Пауза/Возобновить», живой отчёт с показами — недостижимы (0 ₽ + Директ off). +- Реальный флоу «Получить счёт» и «Оплатить картой» — не нажимал (создаёт реальный счёт/платёж на бою). +- Живой админ-экран расход/маржа — нужен админ-доступ. +- Формальный Pa11y-прогон рекламных экранов — нужен локальный авторизованный прогон. + +**Итог Ф0:** подтверждены все 5 замечаний владельца (баг «2 картинки» — как «есть вживую, воспроизвести/добить на Ф3»), добавлено 16 находок (F0-9…F0-24). Дальше — **СТОП на согласование**, затем Ф1 (Дизайн: brainstorming + Frontend Design). diff --git a/docs/superpowers/findings/2026-07-26-direct-media-api-contract.md b/docs/superpowers/findings/2026-07-26-direct-media-api-contract.md new file mode 100644 index 00000000..eefb209a --- /dev/null +++ b/docs/superpowers/findings/2026-07-26-direct-media-api-contract.md @@ -0,0 +1,175 @@ +# Контракт медийного API Яндекс.Директ v5 (за показы) — сверено по документации 26.07.2026 + +> Источник: официальная документация `yandex.ru/dev/direct/doc/ru/*` (WebFetch страниц методов +> `campaigns.add` / `get-cpm-banner-campaign`, `adgroups.add`, `audiencetargets.add`, `ads.add`, +> `creatives.add`). Песочницы для полного доступа нет → **живьём НЕ проверено**; окончательное +> подтверждение — Задача 9 (контролируемый пробный запуск в боевом кабинете при go-live). +> Это источник истины для Задач 3–6 плана Части 4. + +## Общее +- **Деньги — микросы:** все ставки/цены = целое = (сумма в ₽ × 1 000 000), в валюте рекламодателя. + Подтверждено для `AverageCpm`, `SpendLimit`, `DailyBudget.Amount` и в справочнике валют. +- JSON API v5, эндпоинты `/json/v5/`. + +--- + +## 1. `campaigns.add` — CpmBannerCampaign (медийная кампания за показы) + +**CpmBannerCampaignAddItem:** +| Поле | Тип | Обяз. | Примечание | +|---|---|---|---| +| `BiddingStrategy` | CpmBannerCampaignStrategyAdd | **да** | Search + Network | +| `FrequencyCap` | FrequencyCapSetting | нет | ограничение частоты показов | +| `Settings` | array CpmBannerCampaignSetting | нет | YES/NO флаги | +| `CounterIds` | ArrayOfInteger | нет | счётчики Метрики | +| `VideoTarget`, `ExcludedSitesForVideoAds` | — | нет | нам не нужны | + +Плюс общекампанийные поля метода `campaigns.add` (обязательные): `Name`, `StartDate`; +опционально `EndDate`, `ClientInfo` и др. + +**BiddingStrategy (CpmBannerCampaignStrategyAdd):** +- `Search`: `{ "BiddingStrategyType": "SERVING_OFF" }` — **обязательно** (поиск для медийной выключен). +- `Network`: `{ "BiddingStrategyType": , "<НужныйОбъектСтратегии>": {...} }` — **обязательно**. + - enum Network: `MANUAL_CPM` | `CP_DECREASED_PRICE_FOR_REPEATED_IMPRESSIONS` | + `WB_DECREASED_PRICE_FOR_REPEATED_IMPRESSIONS` | `CP_MAXIMUM_IMPRESSIONS` | + `WB_MAXIMUM_IMPRESSIONS` | `CP_AVERAGE_CPV` | `WB_AVERAGE_CPV`. + +**Вложенные объекты стратегий (Network):** +| BiddingStrategyType | Объект | Поля | +|---|---|---| +| `CP_MAXIMUM_IMPRESSIONS` | `CpMaximumImpressions` | `AverageCpm`, `SpendLimit`, `StartDate`, `EndDate`, `AutoContinue` | +| `WB_MAXIMUM_IMPRESSIONS` | `WbMaximumImpressions` | `AverageCpm`, `SpendLimit` | +| `MANUAL_CPM` | `ManualCpm` | `WeeklySpendLimit` (ставка задаётся на группу через `NetworkBid`) | + +**✅ Наш выбор — `CP_MAXIMUM_IMPRESSIONS` / `CpMaximumImpressions`:** максимум показов при заданной +средней цене за 1000 показов (`AverageCpm`), за период (`StartDate`/`EndDate`), с потолком расхода +(`SpendLimit` = бюджет за период) и `AutoContinue` (YES/NO — продлевать ли). Совпадает с исходной +гипотезой плана; подходит и manual-режиму (`run_days`), и auto. (Ранний общий веб-поиск ошибочно +утверждал, что этих имён нет — детальная страница `get-cpm-banner-campaign` их подтверждает.) + +**FrequencyCap (FrequencyCapSetting):** +- `Impressions` (int, **обяз.**) — макс. показов одному пользователю за период. +- `PeriodDays` (int, nillable, **обяз.**) — 1..30; `null` = весь срок кампании. + +**Деньги:** `AverageCpm`, `SpendLimit` — микросы (₽ × 1e6). + +--- + +## 2. `adgroups.add` — CpmBannerKeywordsAdGroup (группа на аудиторию) + +| Поле | Тип | Обяз. | Примечание | +|---|---|---|---| +| `Name` | string 1..255 | **да** | | +| `CampaignId` | long | **да** | id медийной кампании из шага 1 | +| `RegionIds` | array long (≥1) | **да** | регионы; `[0]` — вся страна/без ограничения | +| `CpmBannerKeywordsAdGroup` | структура | **да** | создаётся **пустой**: `{}` | + +- Автотаргетинг для этого типа группы **не применяется** (поля нет) — специально выключать нечего. +- Ключевые слова в этот метод **не передаются**. +- **Аудитория/ретаргетинг цепляется ОТДЕЛЬНЫМ вызовом** `audiencetargets.add` (см. §3), не внутри группы. + +--- + +## 3. Привязка сегмента — `RetargetingLists.add` → `audiencetargets.add` + +- Сегмент Аудиторий → **условие ретаргетинга** через `RetargetingLists.add` + (у нас уже есть `YandexDirectClient::addRetargetingList` — вернёт `RetargetingListId`). +- Затем `audiencetargets.add` вешает условие на медийную группу: + +**AudienceTarget:** +| Поле | Тип | Обяз. | Примечание | +|---|---|---|---| +| `AdGroupId` | long | **да** | id группы из §2 | +| `RetargetingListId` | long | **да** | из `RetargetingLists.add`; уникален в рамках группы | +| `ContextBid` | long | нет | ставка; по умолчанию — мин. ставка. ⚠️ см. go-live | +| `StrategyPriority` | enum | нет | по умолчанию `NORMAL`; только для автостратегий | + +--- + +## 4. `ads.add` — CpmBannerAdBuilderAd (медийное объявление) + +**🔴 Отличие от гипотезы плана:** нет типа `CpmBannerAd` с сырым image-hash. Медийное объявление — +только **`CpmBannerAdBuilderAd`**, ссылается на **готовый креатив по `CreativeId`**: + +```json +"CpmBannerAdBuilderAd": { + "Creative": { "CreativeId": }, // обязательно + "Href": "", // обязательно — ссылка на сайт + "TrackingPixels": { "Items": [""] } // опц., ≤2 +} +``` +- `AdGroupId` (обяз.) — привязка к группе. +- Документация про `Creative`: *«Креатив, загруженный в веб-интерфейсе или созданный в конструкторе + креативов»*. + +**🔴🔴 ГЛАВНАЯ ЗАГВОЗДКА (нужно решение владельца):** креатив медийного баннера **нельзя загрузить +картинкой через API**. `creatives.add` в API v5 умеет создавать **только** `VideoExtensionCreative` +(видеодополнения по `VideoId`) — **не** графические баннеры. Значит `CreativeId` для нашего баннера +получается **только** после создания креатива в **веб-конструкторе Яндекса** (руками), потом читается +через `creatives.get`. Полностью автоматический путь «портал → API → живой медийный баннер» упирается +в ручной шаг создания креатива (аналогично ситуации с Telegram/МТС «руками»). + +Варианты (решает владелец): +1. **Полу-ручной:** креативы заводятся в конструкторе Яндекса один раз, `CreativeId` записывается в + портал (на баннер/на клиента), дальше запуск автоматический. Просто, но нужен ручной шаг на баннеры. +2. **Разведать альтернативу:** HTML5-креатив / другой способ загрузки, другой формат объявления — + требует доп. проверки, не факт что даёт чистый CPM на нашу аудиторию. + +### 🔎 Результат разведки альтернативы (26.07.2026, прямое чтение `creatives.get`/`creatives.add`) +Типы креативов в API (по `creatives.get`): `IMAGE_CREATIVE`, `HTML5_CREATIVE`, `VIDEO_EXTENSION_CREATIVE`, +`CPC_VIDEO_CREATIVE`, `CPM_VIDEO_CREATIVE`, `SMART_CREATIVE` — т.е. графические и HTML5 креативы как +объекты **существуют** и читаются по `CreativeId`. **НО** `creatives.add` принимает **только** +`VideoExtensionCreative` (видеодополнения) — создать через API `IMAGE_CREATIVE`/`HTML5_CREATIVE` +**нельзя**. Отдельного API у Конструктора креативов нет. + +Альтернативный путь `AdImages.add` → `AdImageHash` **работает**, но графические объявления с image-hash +идут в **«Единую перформанс-кампанию»** (перформанс/клики), а НЕ в медийную `CpmBannerCampaign` «за +показы»; старые типы кампаний с 22.05 через API создавать запрещено. То есть авто-заливка картинки +существует только для **другого продукта** с иной логикой показа/оплаты — не для нашей модели показов. + +**Вывод:** для модели «за показы» (Части 1–6) автоматической заливки баннера через API нет. +Единственный путь под нашу модель — **полу-ручной (вариант 1):** креатив-баннер создаётся в +веб-конструкторе Яндекса, портал хранит `CreativeId`, запуск дальше автоматический. Переход на +перформанс-кампанию = отказ от модели показов и переделка Частей 1–6 (большая работа, вне Части 4). + +### ✅ Сколько креативов нужно: ОДИН адаптивный на кампанию (не 15) +Справка Яндекса: в Конструкторе креативов из **одной** картинки делается **адаптивный креатив**, +который сам подстраивается под все размеры блоков РСЯ — *«достаточно создать один адаптивный… +система автоматически создаст объявления всех нужных размеров… под любой рекламный блок»*. Правило: +**одно объявление = один креатив**; с адаптивным креативом **одно** `CpmBannerAdBuilderAd` покрывает +все форматы. Значит ручной шаг пути А = **один креатив на кампанию** (один `CreativeId`), не 15. + +**Следствие для дизайна (уточнение к §4):** `yandex_creative_id` хранить **на кампании** +(`ad_campaigns`), а НЕ на каждой из 15 строк `ad_campaign_banners`. Наши 15 нарезанных размеров +(`BannerSizes`, `BannerGenerator`) остаются для превью/утверждения в портале; в Директ уходит один +адаптивный креатив. Запуск создаёт **одно** медийное объявление `CpmBannerAdBuilderAd` с этим +`CreativeId`. ⚠️ Проверить при go-live: адаптивный креатив реально доступен для медийной (CPM) +кампании и создаётся из одной картинки в конструкторе. + +--- + +## 5. Отчёт по показам (для списания, Часть 6) + +- Отчёт `CAMPAIGN_PERFORMANCE_REPORT`, поля `Impressions` и `Cost` (async TSV). +- ⚠️ Существующий `getCampaignSpend` заточен под клики — при переходе на показы поправить `FieldNames` + (`Impressions`), сверить единицу `Cost` (микросы). Согласовать с `ChargeCampaignSpendJob`. + +--- + +## ⚠️ Проверить живьём при go-live (Задача 9) +1. Нужен ли `ContextBid` для `audiencetargets.add` под стратегией `CP_MAXIMUM_IMPRESSIONS` (или ставка + полностью управляется `AverageCpm` кампании). +2. Обязательность и формат `StartDate`/`EndDate`/`AutoContinue` внутри `CpMaximumImpressions` + (даты — строка `YYYY-MM-DD`; `AutoContinue` — `YES`/`NO`). +3. Семантика `SpendLimit` для `Cp`-стратегии (бюджет за период кампании, не недельный). +4. Как получаем `CreativeId` (ручной конструктор? см. §4) — влияет на дизайн загрузки баннеров. +5. Формат отчёта по показам: точные `FieldNames`, единица `Cost`, async-поллинг. + +## Что совпало с планом / что поправить в Задачах +- ✅ Стратегия `CP_MAXIMUM_IMPRESSIONS` / `CpMaximumImpressions {AverageCpm, SpendLimit, StartDate, + EndDate, AutoContinue}` — как в плане. +- ✅ `FrequencyCap {Impressions, PeriodDays}`, деньги в микросах — как в плане. +- ✅ Группа `CpmBannerKeywordsAdGroup: {}` пустая; аудитория — отдельным `audiencetargets.add`. +- 🔴 Задача 6 плана: объявление — `CpmBannerAdBuilderAd {Creative.CreativeId, Href}`, **не** + `CpmBannerAd {ImageHash}`. Загрузка баннера картинкой через API невозможна — нужен `CreativeId` + из конструктора. Метод клиента вместо `addCpmBannerAd(imageHash)` → `addCpmBannerAd(creativeId, href)`. diff --git a/docs/superpowers/findings/2026-07-27-a11y-kontrast-palitry-portala.md b/docs/superpowers/findings/2026-07-27-a11y-kontrast-palitry-portala.md new file mode 100644 index 00000000..75a5f663 --- /dev/null +++ b/docs/superpowers/findings/2026-07-27-a11y-kontrast-palitry-portala.md @@ -0,0 +1,137 @@ +# Контраст палитры портала не дотягивает до WCAG 2.1 AA — находка 27.07.2026 + +> Всплыло при подключении Pa11y к рекламным экранам (хвост «Pa11y по авторизованным +> рекламным экранам»). **Проблема не рекламная — она общепортальная и уже на бою.** +> Палитра — территория Брендбука (PSR R6.1 hard-override), поэтому сам цвет не менял: +> решение за владельцем. + +## Что мерил + +Цвета темы `liderraForest` — [app/resources/js/plugins/vuetify.ts:115](../../app/resources/js/plugins/vuetify.ts#L115). +Считал контраст текста этим цветом на двух фонах портала: белая карточка `#FFFFFF` +и фон страницы `#F6F3EC` (warm ivory). Норма проекта — **WCAG 2.1 AA, 4.5:1** для +обычного текста (CLAUDE.md §2). + +| Токен | Цвет | На белой карточке | На фоне страницы | Вердикт | +|---|---|---|---|---| +| `primary` | `#0F6E56` | 6.20 | 5.60 | ✅ проходит | +| `error` | `#B83A3A` | 5.67 | 5.12 | ✅ проходит | +| `info` | `#3F7C95` | 4.64 | 4.18 | 🟡 на карточке проходит, **на фоне страницы — нет** | +| `success` | `#2E8B57` | 4.25 | 3.83 | ❌ **не проходит нигде** | +| `warning` | `#D9A441` | 2.25 | 2.03 | ❌ **не проходит с большим запасом** | + +Pa11y подтвердил живьём: на `/admin/advertising` число «Наша маржа» +(`text-success`) — 3.83:1 при требуемых 4.5:1. + +## Насколько широко это разошлось + +`text-success` — **15 мест в 9 файлах** фронта: командный центр админки, биллинг, +карточка тенанта, экран системы, рекламный админ-экран. `warning` живёт в чипах +статусов по всему порталу. То есть это не единичная опечатка, а свойство палитры. + +## Что уже починено (местное, не палитра) + +Чипы статусов «Черновик» и «На паузе» в списке рекламных кампаний были на обычном +`grey` (`#9E9E9E`) — контраст 2.42:1. Заменены на `grey-darken-2`: +[CampaignList.vue](../../app/resources/js/components/advertising/CampaignList.vue). +Серый — не брендовый цвет, поэтому правка безопасна и Брендбука не касается. +После неё `/advertising/yandex` и `/advertising/wallet` — **0 ошибок**. + +## Чего проверка НЕ покрыла (честная граница) + +Pa11y видит только то, что реально нарисовано на экране в момент прогона. На +локальном демо-портале была одна кампания в состоянии «Черновик». Состояния +«Крутится» / «Показы откручены» (`success`) и «Проверяется Яндексом» (`warning`) +на экран не попали — по расчёту контраста они **тоже не пройдут**, но живьём это +не подтверждено. Чтобы закрыть, нужен демо-набор кампаний во всех статусах. + +## ✅ Что сделано по решению владельца 27.07.2026: вариант 2, точечно + +Брендовые `success` и `warning` **не тронуты** — они остаются для заливок, иконок +и рамок, где 4.5:1 не требуется. Рядом добавлены их **текстовые двойники** +в [vuetify.ts](../../app/resources/js/plugins/vuetify.ts): + +| Токен | Цвет | На белой | На ивори | +|---|---|---|---| +| `success-strong` | `#256F46` | 6.10 | 5.51 | +| `warning-strong` | `#8A6410` | 5.37 | 4.85 | + +Все 15 мест `text-success` и 8 мест `text-warning` переведены на +`text-success-strong` / `text-warning-strong` — 13 файлов: командный центр +админки, биллинг админки, инциденты, система, тенанты, интеграция с поставщиком, +карточка тенанта, диалог баланса, рекламный админ-экран, мастер рекламной +кампании, массовые диалоги дней и регионов, реквизиты в настройках. + +Проверено: фронт-тесты **226 файлов / 1663 теста** зелёные; Pa11y по всем 24 +адресам конфига — рекламные экраны и **все админские** дают 0 ошибок. + +## 🔎 Побочно вскрылось: 12 ошибок на клиентских экранах + +Раньше проверка авторизованных страниц вообще не работала (см. ниже), поэтому эти +места никогда не проверялись. **К правке цветов отношения не имеют** — это старые +недочёты, ставшие видимыми: + +| Экран | Что | Сколько | +|---|---|---| +| `/kanban` | прочерк «—» в итогах колонки, низкий контраст | 5 | +| `/projects` | чипы «СМС», «Звонок», «Сайт» — контраст 1.4:1 | 3 | +| `/billing` | таблица с двумя уровнями заголовков без привязки ячеек + 2 декоративные стрелки «→» | 3 | +| `/deals` | поле поиска по телефону без подписи для читалки экрана | 1 | + +Отдельная работа, в этот заход не входила. + +## Варианты решения, которые рассматривались + +1. **Затемнить два токена в самой теме** — одна правка чинит весь портал разом, но + заметно меняет вид: зелёный глуше, жёлтый в горчичный. Это изменение Брендбука. +2. ✅ **Оставить брендовые цвета, чинить точечно** — там, где токен используется + как ТЕКСТ, брать затемнённый двойник. **Выбрано владельцем 27.07.2026**, см. + раздел выше. +3. **Ничего не делать** — зафиксировать как известное отступление от AA. + +## ✅ Чипы статусов проверены живьём — предположение было неверным + +Ранее здесь стояло: «по расчёту `success` и `warning` в чипах тоже не пройдут». +**Это оказалось неправдой.** Завёл на локальном портале кампании во всех восьми +статусах и померил каждый чип по `.v-chip__content` с учётом подложки: + +| Статус | Токен | Было | Стало | +|---|---|---|---| +| Крутится · Показы откручены | `success` | 6.06 ✅ | без изменений | +| Проверяется Яндексом | `warning` | 6.37 ✅ | без изменений | +| Черновик · На паузе | `grey-darken-2` | 4.76 ✅ | без изменений | +| **Готова к запуску** | `info` | **3.63 ❌** | **5.87 ✅** | +| **Отклонено · Остановлено (нет денег)** | `error` | **4.31 ❌** | **5.97 ✅** | + +Причина ошибки в прогнозе: в [app.css](../../app/resources/css/app.css) с аудита +**14.05.2026** уже лежит слой правок, который перекрашивает содержимое тональных +чипов — `success` → `#1f5e3a`, `warning` → `#6a4504`. Мой прежний расчёт брал +брендовый цвет из темы и не учитывал этот слой. Урок: **мерить на живой странице +по тому элементу, где реально лежит текст**, а не считать по токену темы. + +Тот майский слой закрыл только два цвета из четырёх — `info` и `error` остались. +Дописаны туда же, той же глубины (5.87 и 5.97 против 6.06 и 6.37), чтобы все чипы +статусов выглядели ровно. Подложка чипа берёт цвет из `color`-пропа и не меняется — +темнеет только текст. Правка общая по порталу: касается всех тональных чипов. + +Заодно исправлена вторая копия карты статусов в +[CampaignReportDialog.vue](../../app/resources/js/components/advertising/CampaignReportDialog.vue) — +там «Черновик» и «На паузе» оставались на обычном `grey` (2.42:1). + +Проверено: Pa11y по всем 24 адресам — те же 20 из 24, регрессий нет; рекламные и +все админские экраны дают 0 ошибок. + +## Что осталось открытым + +Чипы «СМС», «Звонок», «Сайт» на `/projects` дают **1.4:1** — худший показатель на +портале. Под правку тональных чипов они не попали: там другой вариант отрисовки +(цвет заливкой, а не тоном). Отдельная работа. + +## Побочная находка: проверка доступности была сломана локально + +Все 14 авторизованных экранов в [pa11y.config.json](../../pa11y.config.json) падали +с таймаутом, потому что у локального демо-пользователя `admin@demo.local` пароль +перестал совпадать с тем, что прописан в `DemoSeeder` (`password`). То есть +`npm run a11y` давно проверял только публичные страницы, молча пропуская остальные. +Пароль возвращён к значению из сидера — **только в локальной dev-базе** `liderra` +на 127.0.0.1, боевого это не касается. diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md new file mode 100644 index 00000000..5ffcb94c --- /dev/null +++ b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md @@ -0,0 +1,87 @@ +# Конструктор креативов Яндекса закрыт — находка 27.07.2026 + +> Проверено **живьём в боевом кабинете** `sasha261185` перед проектированием робота-креативщика. +> Ничего не создавалось и не менялось; сделан один read-only запрос к API Директа. +> Снимки экрана — в папке [2026-07-27-yandex-konstruktor-zakryt/](2026-07-27-yandex-konstruktor-zakryt/). + +## Зачем смотрели + +В наших записках было прямое противоречие: + +- [2026-07-26-direct-media-api-contract.md](2026-07-26-direct-media-api-contract.md) §«Сколько креативов + нужно»: *«в Конструкторе креативов из одной картинки делается адаптивный креатив… достаточно создать + один адаптивный»*. Там же стояла пометка **⚠️ проверить при go-live**. +- [HANDOFF прогрева от 25.07](../2026-07-25-HANDOFF-reklama-progrev-mediynaya-kampaniya.md) §5.1: + *«Конструктор креативов Яндекса **закрыт с 01.06.2026** — авто-собрать баннер из логотипа/текста + нельзя, только загрузка готовых файлов»*, и адаптивные пропорции медийная отклонила + («Не отвечает требованиям»). + +От ответа зависела вся Часть C: робот должен был «зайти в конструктор и сделать креатив». + +## Что установлено + +### 1. Конструктора нет — Яндекс говорит об этом сам + +В редакторе объявления медийной кампании висит плашка (`direct-ad-edit.png`): + +> **«Конструктор креативов закрылся 1 июня.** С 30 июня показы объявлений с креативами, +> созданными через конструктор, остановлены. Как адаптироваться к изменениям, читайте в Справке.» + +Отдельного раздела «Креативы» нет ни в «Библиотеке» (`direct-library-menu.png`: сегменты аудитории, +фиды, промоакции, мобильные приложения, минус-фразы, пакетные стратегии, А/В-эксперименты, наборы +филиалов, лендинги, вендоры), ни в «Инструментах» (`direct-tools-menu.png`). + +### 2. Единственный путь — загрузка готовых файлов + +Меню креатива в объявлении (`direct-creative-menu2.png`, кнопка `ImageCreativeEditor.OpenMenu`) +предлагает ровно три пункта: + +- «Готовые креативы HTML5» +- **«Загрузить креативы»** +- **«Выбрать из ранее загруженных»** + +Собрать креатив из одной картинки — негде. + +### 3. Медийная кампания = по объявлению на каждый размер + +Живая кампания прогрева `№713051718` (`direct-banners.png`, статус «Идут показы», 3 568 показов, +расход 1 172,28 ₽) состоит из **15 объявлений**: `300x600`, `300x300`, `728x90`, `240x400`, `240x600`, +`970x250`, `1000x120` и т. д. У каждого свой креатив своего размера, у всех ссылка `https://liderra.ru`. +Никакого «одного адаптивного на все размеры» не существует. + +### 4. `creatives.get` работает и отдаёт эти креативы + +Read-only запрос с боевым токеном (`POST /json/v5/creatives`, `FieldNames: Id, Type, Name, Width, +Height, PreviewUrl, Associated`) вернул все наши баннеры — номера `1163586668`…`1163586682` подряд, +у каждого верный размер. + +Три важные детали, которых мы не знали: + +- **Тип — `HTML5_CREATIVE`**, хотя грузились обычные JPG. Код, отбирающий наши креативы, должен + искать именно этот тип. (Для сравнения: видео лежат как `VIDEO_EXTENSION_CREATIVE`.) +- **Имя у всех одинаковое — «Новый креатив»**: его ставит Яндекс сам при загрузке. Опознавать + креативы по имени **нельзя**. +- **`PreviewUrl` приходит** (`https://avatars.mds.yandex.net/get-media-adv-screenshooter/...`) — + готовая ссылка на превью, своё хранилище картинок для показа клиенту не нужно. + +## Следствия + +1. **Модель «один адаптивный креатив на кампанию» отменяется.** Поле `ad_campaigns.yandex_creative_id` + и единственное объявление `CpmBannerAdBuilderAd` в `CampaignLauncher` — построены на неверном + допущении. Нужен номер креатива **на каждый баннер** и объявление на каждый баннер. +2. **Робот-креативщик меняет назначение:** не «оформитель креатива в конструкторе», а **грузчик + готовых файлов** в кабинет. +3. **Опознание номеров — не по имени.** Рабочий способ: слепок списка креативов «до» и «после» + загрузки + сопоставление по `Width`/`Height`. Требует, чтобы робот работал по одной кампании за + раз и чтобы в наборе не было двух файлов одного размера. +4. **Авто-подгонка из одной картинки у Яндекса осталась только в «перформанс»-кампании** (оплата за + клики). Для нас это отказ от модели «за показы» и переделка Частей 1–6 — не рассматривается + (см. `2026-07-26-direct-media-api-contract.md`, строка про перформанс-кампанию). + +## Почему ошиблись в прошлый раз + +Прошлый вывод был сделан **по справке Яндекса**, а справка описывала конструктор, закрытый к тому +моменту почти два месяца. Пометка «⚠️ проверить при go-live» стояла не зря — проверка окупилась +до траты денег и до постройки робота, который делал бы несуществующую работу. + +**Урок тот же, что и в дозоре по контрасту:** проверять на живом объекте, а не по документации о нём. diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/2026-07-27-ekran-zagruzki-kreativov.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/2026-07-27-ekran-zagruzki-kreativov.png new file mode 100644 index 00000000..9067acd3 Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/2026-07-27-ekran-zagruzki-kreativov.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-ad-edit.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-ad-edit.png new file mode 100644 index 00000000..e354c6b0 Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-ad-edit.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-banners.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-banners.png new file mode 100644 index 00000000..320625e2 Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-banners.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-campaigns.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-campaigns.png new file mode 100644 index 00000000..b0f927df Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-campaigns.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-menu.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-menu.png new file mode 100644 index 00000000..c1ab9a7a Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-menu.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-menu2.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-menu2.png new file mode 100644 index 00000000..f6de67ef Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-menu2.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-picker.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-picker.png new file mode 100644 index 00000000..f21c527e Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-creative-picker.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-library-menu.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-library-menu.png new file mode 100644 index 00000000..fea9dc10 Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-library-menu.png differ diff --git a/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-tools-menu.png b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-tools-menu.png new file mode 100644 index 00000000..327b562e Binary files /dev/null and b/docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/direct-tools-menu.png differ diff --git a/docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-medicina.png b/docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-medicina.png new file mode 100644 index 00000000..df490fb2 Binary files /dev/null and b/docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-medicina.png differ diff --git a/docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-razvernuto.png b/docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-razvernuto.png new file mode 100644 index 00000000..fb0cad60 Binary files /dev/null and b/docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-razvernuto.png differ diff --git a/docs/superpowers/plans/2026-07-25-yandex-reklama-ux-audit-fixes.md b/docs/superpowers/plans/2026-07-25-yandex-reklama-ux-audit-fixes.md new file mode 100644 index 00000000..2092573e --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-yandex-reklama-ux-audit-fixes.md @@ -0,0 +1,436 @@ +# Яндекс-блок рекламы — UX-правки по аудиту: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Устранить UX-дыры во всём клиентском Яндекс-блоке рекламы (кошелёк, мастер 1–4, список/карточка), добавить живые проверки, цену за клик, загрузку своего списка номеров, переиспользование/удаление черновиков и страницу кошелька. + +**Architecture:** Vue 3 + Vuetify 3 фронт (компоненты `resources/js/components/advertising/*`, `views/advertising/*`), Laravel 13 бэкенд (`AdvertisingCampaignController`, `routes/web.php` группа `/api/advertising`). Деньги — `bcmath` + `AdMarkup`/`AdWalletService` (не трогаем — конвертация ÷1,3 уже верна). Лимиты Яндекса — единый источник `CreativeValidator.php`, на фронт выносим синхронную копию-модуль. Работа в git-worktree от `main`. + +**Tech Stack:** Vue 3, Vuetify 3, TypeScript, Vitest (фронт-тесты), Pest 4 (бэк-тесты), PhoneNormalizer (переиспользуем для «моего списка»). + +**Спека:** `docs/superpowers/specs/2026-07-25-yandex-reklama-ux-audit-fixes-design.md`. Находки: `docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md`. + +**Точные лимиты (из `app/app/Services/Advertising/CreativeValidator.php`):** заголовок ≤56 (слово ≤22); второй ≤30 обычных +15 узких (`! , . ; : "`); текст ≤81 обычных +15 узких (слово ≤23); картинка ≥450×450, ≤10 МБ (10*1024*1024), MIME jpeg/png/gif. + +**Prereq (Ф3, до Task 1):** worktree от `main` через `superpowers:using-git-worktrees`; на Windows Laravel-worktree = свой `composer install`, `node_modules` можно junction (см. память `feedback-worktree-laravel-windows`). Первый коммит в ветку — Ф0-находки + спека (docs), под эскейп владельца. + +--- + +## ОЧЕРЕДЬ 1 — быстрые/заметные (в основном шаблон, look-first) + +### Task 1: Текст шага 1 + подпись ползунка + +**Files:** Modify: `app/resources/js/components/advertising/CampaignWizard.vue` (шаг 1 template, ~строки 216–221 «Покажем рекламу…» и ~строка 232 «За сколько дней брать свежие сделки»). + +- [ ] **Step 1: Заменить текст абзаца шага 1** + +Было: +```html +

+ Покажем рекламу тем, кто недавно оставил заявку — выберите, за сколько дней брать контакты. +

+``` +Стало: +```html +

+ Покажем рекламу людям, которых мы нашли по вашим источникам — выберите, за сколько дней брать контакты. +

+``` + +- [ ] **Step 2: Заменить подпись ползунка** + +Было: `За сколько дней брать свежие сделки` +Стало: `За сколько дней брать свежие контакты` + +- [ ] **Step 3: Проверка в браузере (Playwright) шага 1** — открыть мастер, убедиться, что новый текст и подпись отображаются. + +- [ ] **Step 4: Commit** — `git add app/resources/js/components/advertising/CampaignWizard.vue && git commit -m "fix(реклама): текст шага «Кого рекламируем» и подпись ползунка"` + +--- + +### Task 2: Убрать «Бюджет в день» из мастера + фикс отображения бюджета + +**Files:** Modify: `app/resources/js/components/advertising/CampaignWizard.vue` (шаг 3 template — поле `daily_budget_rub`; форма `form.daily_budget_rub`; вызовы `patchCampaign`/сводка шаг 4). + +- [ ] **Step 1: Убрать поле «Бюджет в день (необязательно)»** — удалить `v-text-field` с `v-model="form.daily_budget_rub"` на шаге 3. + +- [ ] **Step 2: В `saveBudgetStep` перестать слать daily** — в `patchCampaign(...)` убрать ключ `daily_budget_rub` (Яндекс при ручной стратегии его игнорирует; колонку БД не трогаем). + +- [ ] **Step 3: Убрать «.00» в отображении недельного бюджета** — там где бюджет показывается (шаг 3 hint / сводка шаг 4), форматировать через существующий `formatPlain(Number(form.weekly_budget_rub))` (как в `CampaignList.vue`), а не сырую строку `1000.00`. + +- [ ] **Step 4: Проверка Playwright** — шаг 3 без поля «в день»; сводка шага 4 «1 000 ₽» без «.00». + +- [ ] **Step 5: Commit** — `git commit -m "fix(реклама): убрать неработающий «Бюджет в день», формат бюджета без .00"` + +--- + +### Task 3: Спрятать «Отчёт» на черновике + +**Files:** Modify: `app/resources/js/components/advertising/CampaignList.vue` (карточка кампании, кнопка «Отчёт»). + +- [ ] **Step 1: Обернуть кнопку «Отчёт» условием** — показывать только если `campaign.status !== 'draft'`: +```html +Отчёт +``` + +- [ ] **Step 2: Проверка Playwright** — у черновика кнопки «Отчёт» нет, есть «Изменить» (и «Удалить» после Task 15). + +- [ ] **Step 3: Commit** — `git commit -m "fix(реклама): не показывать «Отчёт» у черновика"` + +--- + +### Task 4: Доступное имя вкладок «Мои кампании / Новая реклама» + +**Files:** Modify: `app/resources/js/views/advertising/AdvertisingYandexView.vue` (Vuetify `v-tabs`/`v-tab`). + +- [ ] **Step 1: Прочитать текущий блок вкладок** в `AdvertisingYandexView.vue` (найти ``/``). + +- [ ] **Step 2: Добавить каждому `v-tab` доступное имя** — либо явный текст-лейбл внутри таба, либо `aria-label="Мои кампании"` / `aria-label="Новая реклама"`, чтобы accessible name не был пустым (в Ф0 дерево доступности показало пустые имена). + +- [ ] **Step 3: Проверка через Playwright a11y-проб** — `document.querySelectorAll('[role=tab]')` accessible name непустой; либо повторить `browser_snapshot` — у tab появилось имя. + +- [ ] **Step 4: Commit** — `git commit -m "fix(a11y): доступные имена вкладок рекламного экрана"` + +--- + +### Task 5: Значок чата не перекрывает «Далее» в мастере + +**Files:** Modify: компонент плавающего чата (FAB 💬) ИЛИ футер мастера. Найти: `git grep -n "Открыть чат поддержки\|support-chat\|jivo\|chat" app/resources/js`. + +- [ ] **Step 1: Найти FAB чата** и его `z-index`/`position` + футер мастера с кнопкой «Далее». + +- [ ] **Step 2: Развести перекрытие** — добавить нижний отступ футеру мастера на мобильном/узком (`padding-bottom`) ИЛИ приподнять кнопки мастера над FAB, чтобы 💬 не накрывал «Далее». Не менять положение FAB глобально (он общий для портала). + +- [ ] **Step 3: Проверка Playwright** — на шаге 2 при 1440px и 390px кнопка «Далее» полностью видна и кликабельна, 💬 не поверх неё. + +- [ ] **Step 4: Commit** — `git commit -m "fix(реклама): чат-кнопка не перекрывает «Далее» в мастере"` + +**Ревью Очереди 1:** Frontend Design (визуал/токены/состояния) + Playwright прогон экранов. STOP на согласование перед Очередью 2. + +--- + +## ОЧЕРЕДЬ 2 — проверки формы + баг картинки + цена за клик (логика → TDD) + +### Task 6: Единый фронт-модуль лимитов креатива (синхрон с CreativeValidator) + +**Files:** Create: `app/resources/js/lib/advertising/creativeLimits.ts`; Test: `app/resources/js/lib/advertising/__tests__/creativeLimits.spec.ts`. + +- [ ] **Step 1: Написать падающие тесты** (Vitest): +```ts +import { describe, it, expect } from 'vitest'; +import { validateTitle, validateTitle2, validateText, NARROW_CHARS } from '../creativeLimits'; + +describe('creativeLimits', () => { + it('заголовок ровно 56 — ок, 57 — ошибка', () => { + expect(validateTitle('a'.repeat(56))).toBeNull(); + expect(validateTitle('a'.repeat(57))).toMatch(/56/); + }); + it('слово длиннее 22 в заголовке — ошибка', () => { + expect(validateTitle('a'.repeat(23))).toMatch(/22/); + }); + it('второй заголовок: 30 обычных ок, 31 — ошибка; узкие считаются отдельно', () => { + expect(validateTitle2('a'.repeat(30))).toBeNull(); + expect(validateTitle2('a'.repeat(31))).not.toBeNull(); + expect(validateTitle2('a'.repeat(30) + '!!!!!')).toBeNull(); // 30 обычных + 5 узких + }); + it('текст: 81 обычных ок, 82 — ошибка; слово >23 — ошибка', () => { + expect(validateText('a'.repeat(81))).toBeNull(); + expect(validateText('a'.repeat(82))).not.toBeNull(); + expect(validateText('a'.repeat(24))).toMatch(/23/); + }); +}); +``` + +- [ ] **Step 2: Запустить — упасть** — `npm run test:vue -- creativeLimits` → FAIL (модуля нет). + +- [ ] **Step 3: Реализовать `creativeLimits.ts`** портом логики `CreativeValidator` (те же константы 56/22/30/15/81/15/23, тот же список узких символов, подсчёт обычных/узких раздельно, разбивка слов по `\s+`). Возвращает строку-ошибку по-русски или `null`. + +- [ ] **Step 4: Запустить — пройти** — `npm run test:vue -- creativeLimits` → PASS. + +- [ ] **Step 5: Commit** — `git commit -m "feat(реклама): фронт-модуль лимитов креатива (синхрон с CreativeValidator)"` + +--- + +### Task 7: Живые счётчики символов в форме объявления + +**Files:** Modify: `app/resources/js/components/advertising/CreativeForm.vue` (поля title/title2/text). + +- [ ] **Step 1: Подключить `creativeLimits`** — computed-ошибки `titleError = validateTitle(title.value)` и т.д. + +- [ ] **Step 2: Добавить счётчики/правила Vuetify** — на `v-text-field`/`v-textarea` добавить `:counter="56"` (title), `:counter="30"` (title2), `:counter="81"` (text) и `:error-messages="[...(fieldErrors?.title ?? []), ...(liveTitleError ? [liveTitleError] : [])]"` — живая ошибка + серверная. + +- [ ] **Step 3: Проверка Playwright** — ввод 77 символов в заголовок сразу показывает предупреждение и счётчик 77/56 (в Ф0 было молчание). + +- [ ] **Step 4: Commit** — `git commit -m "feat(реклама): живые счётчики и проверка длин в форме объявления"` + +--- + +### Task 8: Клиентская проверка ссылки (URL) + +**Files:** Create: `app/resources/js/lib/advertising/urlRule.ts`; Test: `.../__tests__/urlRule.spec.ts`; Modify: `CreativeForm.vue`. + +- [ ] **Step 1: Падающий тест**: +```ts +import { isValidHttpUrl } from '../urlRule'; +it('только http/https', () => { + expect(isValidHttpUrl('https://liderra.ru')).toBe(true); + expect(isValidHttpUrl('http://a.b')).toBe(true); + expect(isValidHttpUrl('это_не_ссылка')).toBe(false); + expect(isValidHttpUrl('ftp://x')).toBe(false); + expect(isValidHttpUrl('')).toBe(false); +}); +``` + +- [ ] **Step 2: Запустить — упасть**. + +- [ ] **Step 3: Реализовать `isValidHttpUrl`** через `new URL()` + проверку протокола http/https. + +- [ ] **Step 4: Запустить — пройти**. + +- [ ] **Step 5: Подключить в `CreativeForm.vue`** — живая ошибка «Укажите адрес вида https://ваш-сайт.ru» под полем ссылки при непустом невалидном вводе. + +- [ ] **Step 6: Проверка Playwright** — «это_не_ссылка» подсвечивается сразу. + +- [ ] **Step 7: Commit** — `git commit -m "feat(реклама): клиентская проверка адреса сайта"` + +--- + +### Task 9: Клиентская проверка картинки (размер/вес/MIME) + +**Files:** Create: `app/resources/js/lib/advertising/imageMeta.ts` (чтение размеров) + `imageRule.ts`; Test: `.../__tests__/imageRule.spec.ts`; Modify: `CreativeForm.vue`. + +- [ ] **Step 1: Падающий тест на правило** (чистая функция, без DOM): +```ts +import { checkImage } from '../imageRule'; +it('размеры/вес/формат', () => { + expect(checkImage({width:500,height:500,bytes:1000,mime:'image/png'})).toBeNull(); + expect(checkImage({width:449,height:500,bytes:1000,mime:'image/png'})).toMatch(/450/); + expect(checkImage({width:500,height:500,bytes:11*1024*1024,mime:'image/png'})).toMatch(/10/); + expect(checkImage({width:500,height:500,bytes:1000,mime:'image/webp'})).toMatch(/формат/i); +}); +``` + +- [ ] **Step 2: Запустить — упасть**. + +- [ ] **Step 3: Реализовать `checkImage`** — те же пороги, что `CreativeValidator::validateImage` (min 450, 10 МБ, jpeg/png/gif). И `imageMeta.ts::readImageMeta(file): Promise<{width,height,bytes,mime}>` через `createObjectURL` + `Image()` (revokeObjectURL после). + +- [ ] **Step 4: Запустить — пройти**. + +- [ ] **Step 5: Подключить в `CreativeForm.vue`** — при выборе файла `readImageMeta` → `checkImage` → ошибка в `:error-messages` поля картинки до отправки. + +- [ ] **Step 6: Проверка Playwright** — загрузка мелкой/тяжёлой/webp картинки даёт понятную ошибку сразу. + +- [ ] **Step 7: Commit** — `git commit -m "feat(реклама): клиентская проверка размеров/веса/формата картинки"` + +--- + +### Task 10: Дружелюбные подписи серверных ошибок 422 + +**Files:** Modify: `app/resources/js/components/advertising/CreativeForm.vue` (маппинг ключей `title/title2/text/href/image` на русские подписи в выводе `fieldErrors`). + +- [ ] **Step 1: Ввести словарь подписей** `{ title:'Заголовок', title2:'Второй заголовок', text:'Текст объявления', href:'Ссылка на сайт', image:'Картинка' }` и заменять в тексте серверной ошибки имя поля на подпись (или, проще, показывать серверный текст ПОД правильным полем, а сам текст очищать от «поле title»-префикса регуляркой на русскую подпись). + +- [ ] **Step 2: Проверка Playwright** — отправка с длинным заголовком/битой ссылкой даёт «Заголовок длиннее 56…», а не «поле title…». + +- [ ] **Step 3: Commit** — `git commit -m "fix(реклама): человеческие подписи ошибок формы объявления"` + +--- + +### Task 11: «Текст объявления» — обязательное поле + +**Files:** Modify: `CreativeForm.vue`. + +- [ ] **Step 1: Пометить обязательным** — лейбл «Текст объявления *» + правило `:rules="[v => !!v || 'Заполните текст объявления']"`; блокировать «Добавить объявление» при пустом тексте (или показывать ошибку до серверного 422). + +- [ ] **Step 2: Проверка Playwright** — пустой текст явно помечен и не уходит молча в 422. + +- [ ] **Step 3: Commit** — `git commit -m "fix(реклама): текст объявления помечен обязательным"` + +--- + +### Task 12: Баг «2 картинки» — воспроизвести и починить + +**Files:** Test: `app/resources/js/components/advertising/__tests__/CreativeForm.image.spec.ts`; Modify: `CreativeForm.vue` (обработка `v-file-input` `v-model="file"`). + +- [ ] **Step 1: REQUIRED SUB-SKILL — `superpowers:systematic-debugging`.** Сначала воспроизвести двоение (Playwright на живом + проверка фактического значения `file` в разных версиях выбора: одиночный выбор, повторный выбор, замена файла). Зафиксировать точную причину (гипотезы: `v-file-input` отдаёт `File[]` при `File|null`-типе; либо двойной `@change`/`@update:modelValue`; либо связка normal/wide). + +- [ ] **Step 2: Падающий тест** (Vitest + @vue/test-utils), воспроизводящий двоение по найденной причине — например, что после выбора одного файла в модели/загрузке оказывается один `File`, а не массив/два. + +- [ ] **Step 3: Запустить — упасть**. + +- [ ] **Step 4: Фикс** по найденной причине (например, нормализовать `file` к одиночному: `Array.isArray(v) ? v[0] ?? null : v`; или снять дубль обработчика). + +- [ ] **Step 5: Запустить — пройти** + Playwright: одна картинка добавляется как одна. + +- [ ] **Step 6: Commit** — `git commit -m "fix(реклама): картинка объявления не двоится при загрузке"` + +--- + +### Task 13: Поле «Цена за клик» (шаг 3) + в сводку (шаг 4) + +**Files:** Modify: `CampaignWizard.vue` (форма `form` + шаг 3 template + сводка шаг 4). API `createCampaign`/`patchCampaign` уже принимают `click_bid_rub` (см. `api/advertising.ts::CampaignCreate`). + +- [ ] **Step 1: Добавить в `form`** поле `click_bid_rub: '10.00'` (дефолт как в `CampaignLauncher`, не выдумывать иной). + +- [ ] **Step 2: Поле на шаге 3** — `v-text-field type="number"` «Цена за клик», suffix «₽», подсказка «Сколько максимум готовы платить за один переход». Слать в `saveBudgetStep` через `patchCampaign({... click_bid_rub: form.click_bid_rub})`. + +- [ ] **Step 3: В сводку шага 4** добавить строку «Цена за клик: {{ форматированный click_bid_rub }} ₽» и «Сайт: {{ ссылка первого объявления }}». + +- [ ] **Step 4: Проверка Playwright** — поле цены клика видно на шаге 3, значение попадает в сводку шага 4. + +- [ ] **Step 5: Commit** — `git commit -m "feat(реклама): цена за клик в мастере и сводке"` + +**Ревью Очереди 2:** `superpowers:requesting-code-review` (логика/тесты) + Frontend Design (UI) + Pa11y-локально. STOP на согласование перед Очередью 3. + +--- + +## ОЧЕРЕДЬ 3 — крупное (backend + фронт + внешние зависимости) + +### Task 14: Переиспользование черновика при входе в мастер + +**Files:** Modify: `app/resources/js/components/advertising/CampaignWizard.vue` (`createDraft`/`onMounted`); при необходимости — `AdvertisingCampaignController@index`/новый параметр, но проще на фронте. + +- [ ] **Step 1: На фронте перед `createDraft`** — запросить список кампаний (`fetchCampaigns`), найти последний со `status==='draft'`; если есть — войти в режим правки этого черновика (`loadForEdit(id)`), иначе создавать новый. Это устраняет плодение черновиков. + +- [ ] **Step 2: Проверка Playwright** — двойной вход в «Новая реклама» не создаёт двух черновиков (в списке остаётся один незапущенный). + +- [ ] **Step 3: Commit** — `git commit -m "fix(реклама): переиспользовать черновик вместо создания нового при каждом входе"` + +--- + +### Task 15: Удаление черновика (backend + кнопка) + +**Files:** Modify: `app/routes/web.php` (группа `/api/advertising`), `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` (метод `destroy`), `app/resources/js/api/advertising.ts` (fn `deleteCampaign`), `CampaignList.vue` (кнопка «Удалить»); Test: `app/tests/Feature/Advertising/DeleteCampaignTest.php`. + +- [ ] **Step 1: REQUIRED SUB-SKILL — `superpowers:test-driven-development`. Падающий Pest-тест**: DELETE `/api/advertising/campaigns/{id}` удаляет только `draft` своего тенанта (204); чужой тенант / не-draft → 403/409; RLS-изоляция соблюдена. + +- [ ] **Step 2: Запустить — упасть** (`composer test -- --filter=DeleteCampaign`). + +- [ ] **Step 3: Роут** — `Route::delete('/campaigns/{id}', '...AdvertisingCampaignController@destroy')->whereNumber('id');` + +- [ ] **Step 4: Метод `destroy`** — проверка tenant + `status==='draft'`, удалить кампанию (и связанные `ad_creatives`/`ad_campaign_phones` каскадом/явно). Не трогать запущенные. + +- [ ] **Step 5: Запустить — пройти**. + +- [ ] **Step 6: Фронт** — `deleteCampaign(id)` в `api/advertising.ts`; кнопка «Удалить» на карточке черновика в `CampaignList.vue` с подтверждением; после — `loadCampaigns()`. + +- [ ] **Step 7: Проверка Playwright** — черновик удаляется, список обновляется. + +- [ ] **Step 8: Commit** — `git commit -m "feat(реклама): удаление черновика кампании"` + +--- + +### Task 16: Различимое имя черновика + +**Files:** Modify: `CampaignWizard.vue::autoName()` и/или `CampaignList.vue` отображение. + +- [ ] **Step 1: Сделать имя информативнее** — например «Реклама от ДД.ММ.ГГГГ №{id}» или подставлять заголовок первого объявления, если есть. Простой вариант: в `CampaignList.vue` к имени черновика добавлять `#{id}`. + +- [ ] **Step 2: Проверка Playwright** — несколько черновиков различимы. + +- [ ] **Step 3: Commit** — `git commit -m "fix(реклама): различимые имена черновиков"` + +--- + +### Task 17: «Мой список» — backend загрузки телефонов + +**Files:** Modify: `routes/web.php`, `AdvertisingCampaignController.php` (метод `storePhones`); переиспользовать `App\Support\PhoneNormalizer`; Test: `app/tests/Feature/Advertising/UploadPhonesTest.php`. + +- [ ] **Step 1: REQUIRED SUB-SKILL — `superpowers:test-driven-development`. Падающий Pest-тест**: POST `/api/advertising/campaigns/{id}/phones` принимает либо файл (CSV/XLSX), либо текст со списком номеров; нормализует через `PhoneNormalizer` к `79XXXXXXXXX`, дедуп, пишет в `ad_campaign_phones` (tenant_id, campaign_id); возвращает `{recognized, skipped}`; чужой тенант → 403; RLS. **В фикстурах — синтетические номера, НЕ реальные ПДн** (gitleaks не смотрит в tests/, см. память). + +- [ ] **Step 2: Запустить — упасть**. + +- [ ] **Step 3: Роут** — `Route::post('/campaigns/{id}/phones', '...@storePhones')->whereNumber('id');` + +- [ ] **Step 4: Метод `storePhones`** — валидация (файл `mimes:csv,txt,xlsx max:...` ИЛИ поле `text`), парс строк, `PhoneNormalizer::normalize` каждой, отбросить невалидные/дубли, upsert в `ad_campaign_phones`. Вернуть счётчики. + +- [ ] **Step 5: Запустить — пройти**. + +- [ ] **Step 6: Commit** — `git commit -m "feat(реклама): загрузка своего списка номеров кампании (backend)"` + +> ⚠️ Если `ad_campaign_phones` под RLS и добавляются политики — пере-применить `db/03_service_bypass_policies.sql` (грабля srv_bypass, память выката). Таблица уже существует (лаунчер её читает) — проверить наличие GRANT/политик. + +--- + +### Task 18: «Мой список» — фронт загрузки в шаге 1 + +**Files:** Modify: `CampaignWizard.vue` (шаг 1, блок под тумблером `use_uploaded_list`), `api/advertising.ts` (fn `uploadCampaignPhones`). + +- [ ] **Step 1: `uploadCampaignPhones(id, {file?|text?})`** в `api/advertising.ts` (FormData для файла / JSON для текста). + +- [ ] **Step 2: Блок загрузки под тумблером** — когда `use_uploaded_list` включён: `v-file-input` (CSV/XLSX) + `v-textarea` «или вставьте номера» + кнопка «Загрузить»; после ответа показать «Распознано N, отброшено M номеров». Пояснение про согласие/ПДн. + +- [ ] **Step 3: Проверка Playwright** — включение тумблера показывает загрузку; тестовый список синтетических номеров загружается, счётчики видны (в Ф0 тумблер был мёртвый). + +- [ ] **Step 4: Commit** — `git commit -m "feat(реклама): загрузка своего списка номеров (фронт)"` + +--- + +### Task 19: Страница «Рекламный кошелёк» + пункт меню + +**Files:** Create: `app/resources/js/views/advertising/AdWalletView.vue`; Modify: `app/resources/js/router` (роут `/advertising/wallet`), `components/layout/advertisingChannels.ts` или `AppSidebar.vue` (пункт меню «Рекламный кошелёк»). + +- [ ] **Step 1: Пункт меню** — добавить «Рекламный кошелёк» (icon `mdi-wallet-outline`, route `/advertising/wallet`) в секцию «Рекламные возможности» (отдельно от `AD_CHANNELS`, т.к. это не канал; либо новый массив/пункт в `AppSidebar.vue`). + +- [ ] **Step 2: Роут + `AdWalletView.vue`** — страница: баланс (Кошелёк/Заморожено/Свободно) **с подсказками** что это; кнопка «Пополнить» (открывает `AdWalletTopupDialog`); лента истории пополнений/списаний (если есть API транзакций кошелька — использовать; если нет — показать баланс + пополнение, историю пометить как следующий инкремент, НЕ выдумывать эндпоинт). + +- [ ] **Step 3: Проверка Playwright** — пункт меню ведёт на страницу, баланс и пояснения видны. + +- [ ] **Step 4: Commit** — `git commit -m "feat(реклама): страница рекламного кошелька + пункт меню"` + +--- + +### Task 20: Переделка окна «Пополнить» + +**Files:** Modify: `app/resources/js/components/advertising/AdWalletTopupDialog.vue`. + +- [ ] **Step 1: Прочитать текущий `AdWalletTopupDialog.vue`** (кнопки, вёрстка, disabled-стиль). + +- [ ] **Step 2: Пресеты сумм** — чипы 1000/3000/5000 + «своя сумма»; минимум 100 ₽ оставить. + +- [ ] **Step 3: Починить вёрстку кнопок** — на узком/1440px «Закрыть» не обрезается; кнопки в столбик при нехватке ширины; выключенная «Оплатить картой» стилизована как выключенная (не сплошная зелёная). + +- [ ] **Step 4: Проверка Playwright** — 1440px и 390px: все кнопки читаемы, disabled-состояние явное, пресеты работают. + +- [ ] **Step 5: Commit** — `git commit -m "fix(реклама): переделка окна пополнения кошелька (пресеты, вёрстка, состояния)"` + +--- + +### Task 21: Проверить/починить оплату картой + +**Files:** Modify (по итогам проверки): `AdWalletTopupDialog.vue` (обработка результата `topupAdvertisingByCard`). + +- [ ] **Step 1: Проверить happy-path** — `topupAdvertisingByCard` уже шлёт `/api/billing/topup` credit_target=advertising; при ВКЛ `billing_yookassa_enabled` возвращает `confirmation_url` (редирект). Убедиться, что фронт делает редирект на `confirmation_url`, а при заглушке (`ok:true`) — обновляет баланс. + +- [ ] **Step 2: Обработка ошибок** — если оплата не создаётся / ЮKassa вернула отказ — показать понятную ошибку, не «тихий тупик». Добавить обработку отсутствия `confirmation_url` и `ok`. + +- [ ] **Step 3: ⚠️ Живая проверка картой на бою — ТОЛЬКО с владельцем** (go-live ЮKassa не завершён; happy-path картой в бою не проверялся). Без живой успешной оплаты «готово по карте» НЕ заявлять — зафиксировать как внешнюю блокировку, если платёж не проходит. + +- [ ] **Step 4: Commit** — `git commit -m "fix(реклама): корректный редирект/ошибки при оплате кошелька картой"` + +**Ревью Очереди 3:** code-review (логика/RLS через `rls-reviewer` для новых таблиц/политик) + Frontend Design + Pa11y. STOP. + +--- + +## Ф4 Проверка (после реализации) +- `superpowers:requesting-code-review` по всем изменениям; `rls-reviewer` для Task 15/17 (удаление/телефоны). +- Локальный авторизованный прогон **Pa11y** по рекламным экранам (текущий `pa11y.config.json` бьёт по публичным localhost-страницам — добавить авторизованный сценарий рекламных экранов). +- Полный прогон `/regression full` (Pest --parallel, Vitest, Vite build, larastan). + +## Ф5 Выкат +- По ранбуку `docs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md`: свод в `main` → пуш gitea (GitHub мёртв) → билд-дерево → миграции на кластер (если появятся — способ §6b, под crm_migrator) → пере-применить `db/03_service_bypass_policies.sql` при новых RLS-таблицах → рестарт php-fpm/queue → smoke. +- Повторный прогон Playwright по всем 6 экранам. Живая проверка карты — с владельцем. +- Удалить лишний черновик №4, созданный при разведке Ф0. + +--- + +## Self-review (соответствие спеке) +- §4.A кошелёк → Task 19 (страница+меню), Task 20 (окно), Task 21 (карта). ✅ +- §4.B шаг 1 → Task 1 (текст/ползунок), Task 17+18 (мой список). ✅ +- §4.C шаг 2 → Task 6–12 (лимиты, счётчики, url, картинка, ошибки, обязательность, баг картинки). ✅ +- §4.D шаг 3 → Task 2 (убрать «в день»), Task 13 (цена клика). ✅ +- §4.E шаг 4 → Task 13 (сводка). ✅ +- §4.F список/карточка → Task 3 (отчёт на черновике), Task 14 (переиспользование), Task 15 (удаление), Task 16 (имена). ✅ +- §4.G a11y/мобильный → Task 4 (вкладки), Task 5 (чат), Ф4 (Pa11y), Task 20 (мобильный топап). ✅ +- §4.H админка → Ф-отдельно (админ-доступ). ⏳ вне этого плана (ждёт доступ). +- Денежная корректность §2 — не трогаем (верно). ✅ diff --git a/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md new file mode 100644 index 00000000..654f925d --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast4-direct-medijnaya.md @@ -0,0 +1,261 @@ +# План Части 4 — связь с Директом: медийная кампания «за показы» + +> **Для исполнителя:** ОБЯЗАТЕЛЬНЫЙ СУБ-СКИЛ — `superpowers:subagent-driven-development` (TDD, субагент НЕ коммитит; контроллер ревьюит diff, гоняет тесты, коммит по «go»). Шаги — чекбоксами. + +**Goal:** Переписать запуск рекламной кампании в Яндекс.Директ с текстовой кампании «за клики» на **медийную кампанию «за показы» (`CpmBannerCampaign`)**, строго на собранный сегмент, под уже готовую модель показов (`frequency`, `estimated_impressions`, `client_cpm_rub`, `ad_margin_percent`). Обкатка — в песочнице Директа; боевой рубильник `yandex_direct.enabled` остаётся ВЫКЛ до отдельного «go» владельца. + +**Architecture:** `CampaignLauncher.launch()` оркеструет цепочку через `YandexDirectClient`: сегмент Аудиторий → ретаргетинг-условие → медийная кампания (`CpmBannerCampaign`, стратегия сети `CP_MAXIMUM_IMPRESSIONS`, `FrequencyCap`, `SpendLimit` = бэкстоп расхода Яндекса) → группа `CpmBannerKeywordsAdGroup` (автотаргетинг OFF, без ключей, единственное условие = наш сегмент) → медийные объявления (`CpmBannerAd`) на модерацию → заморозка клиентских денег в кошельке. Деньги: клиент платит `client_cpm_rub`; в Директ уходит `× (1 − ad_margin_percent/100)` (наценка клиенту не видна). Всё за рубильником. + +**Tech Stack:** PHP 8.3 / Laravel 13, PostgreSQL 16 (RLS), Pest 4 (`Http::fake`), bcmath (scale 2, микросы — целые). Yandex Direct API v5 (JSON). + +**Money-инварианты (боевой прод!):** `yandex_cost_rub` / `ad_margin_percent` / расход Яндекса — НИКОГДА в клиентском JSON (уже в `$hidden`; добавить тест). Заморозка при запуске — по оценке сметы в клиентских ₽, идемпотентна. Списание по факту — уже готово (Часть 6, `ChargeCampaignSpendJob`). + +--- + +## Контекст: что уже есть, что меняем + +**Готово (не трогаем логику, только опираемся):** +- Модель `AdCampaign` уже на показы: `mode` (auto|manual), `frequency`, `frequency_period_days`, `estimated_impressions`, `paid_impressions`, `delivered_impressions`, `client_cpm_rub`, `yandex_cost_rub` (в `$hidden`), `effectiveCpm()`, `snapshot_from/to`, `run_days`. Легаси-поля `weekly_budget_rub`, `click_bid_rub` ещё в fillable — вычистим (Задача 8). +- `CampaignAudienceBuilder` — собирает телефоны по режиму (auto: окно `audience_days`; manual: диапазон дат + свой список). Отдаёт массив телефонов. +- `CampaignEstimateService` / `audienceSize` — смета `показы × cpm/1000`. +- `CampaignImpressionCharger` (Часть 6) — списание по факту показов, пишет `yandex_cost_rub = списано × (1 − margin/100)`. +- `YandexAudienceClient` — создание сегмента Аудиторий (upload + confirm). +- `YandexDirectClient` — ЕСТЬ: `addRetargetingList`, `suspendCampaign`, `resumeCampaign`, `getAdsModeration`, `getCampaignSpend`. НЕТ медийных методов. +- Контроллер: `submit()` → статус `queued` (комментарий «реальный запуск доделает Часть 4»); `launch()` → зовёт старый `CampaignLauncher->launch()` (текстовая кампания за клики). +- Токен Директа установлен на бой (`YANDEX_DIRECT_TOKEN`), доступ к API **одобрен** (полный). `enabled=false`, `base_url=api-sandbox…`. + +**Меняем:** `YandexDirectClient` (+медийные методы, старый `addCampaign(TextCampaign)`/`addAudienceTarget(ContextBid)`/`addTextAd` → медийные), `CampaignLauncher.launch()` (пересчёт под показы), контроллер `submit/launch` (флоу запуска), чистка легаси клик-полей, сверка наценки 30%→`ad_margin_percent` 40%. + +**🔴 Песочницы нет (решено 26.07.2026):** для аккаунта `sasha261185` с уже одобренным **полным** доступом отдельная песочница Директа недоступна (`campaigns.get` к `api-sandbox` → err 513 «логин не подключён»; песочница выдаётся только под *тестовый* доступ, который мы переросли). Решение владельца: **код Части 4 пишем строго по актуальной документации API v5 + полностью проверяем `Http::fake`-тестами (без Яндекса); живую сверку делаем ОДИН раз при go-live** — контролируемый пробный запуск в боевом кабинете под присмотром владельца, с немедленной остановкой. Задача 1 — сверка контракта медийного API по документации (не живьём). **Рискованные места (точные имена enum/полей, где микросы) помечаем в findings как «проверить при go-live».** + +--- + +## File Structure + +- Modify: `app/app/Services/Advertising/YandexDirectClient.php` — +медийные методы, заменить клик-методы. +- Modify: `app/app/Services/Advertising/CampaignLauncher.php` — переписать `launch()` под показы. +- Modify: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` — флоу `submit`/`launch`. +- Modify: `app/config/services.php` — параметры медийной стратегии (регион, дефолты), рубильник. +- Modify: `app/app/Models/AdCampaign.php` — убрать легаси клик-поля из fillable/casts. +- Create: `app/tests/Unit/Advertising/YandexDirectMediaClientTest.php` — юнит на построение JSON медийных методов (Http::fake). +- Modify/Create: `app/tests/Feature/Advertising/CampaignLauncherTest.php` — запуск медийной цепочки (Http::fake), money-инварианты. +- Modify: `app/tests/Feature/Advertising/CampaignSubmitTest.php` — обновить под новый флоу submit/launch (если меняется). +- Create: `docs/superpowers/findings/2026-07-XX-direct-media-api-sandbox-verified.md` — фиксация реальных полей API из Задачи 1. + +**Нужна одна аддитивная миграция** (вскрыто Задачей 1 + вопросом владельца про 15 размеров): +- `ad_campaigns.yandex_creative_id` (nullable bigint) — **номер адаптивного креатива Яндекса на + кампанию** (один адаптивный креатив покрывает все 15 размеров, см. findings §4). Хранить на + кампании, НЕ на `ad_campaign_banners`. +- `ad_campaign_banners` GRANT сейчас `SELECT, INSERT, DELETE` для `crm_app_user` — если номер вводит + клиент, нужен UPDATE; но т.к. номер на кампании и вводит **оператор в админке** (роль + `crm_admin_user`/`crm_app_admin`), проверить, что у admin-роли есть UPDATE на `ad_campaigns` для + этого поля. Миграция аддитивна: только `ADD COLUMN` + при необходимости GRANT. Обязательно + `db/CHANGELOG_schema.md` + прогон `rls-reviewer`. Помнить про `srv_bypass` (память + `project-reklama-modul-vykat-2026-07-25`): новая колонка на существующей таблице — политики не + трогаем, но если admin-экран читает под `crm_admin_user` — сверить доступ. + +Ввод номера креатива оператором — маленькое поле в админ-экране «Рекламные кампании» +(`AdminAdvertisingController` + `AdminAdvertisingView.vue`); по умолчанию вводит оператор (не клиент). + +--- + +## Task 1: Сверка контракта медийного API v5 по документации (песочницы нет) + +**Files:** Create `docs/superpowers/findings/2026-07-26-direct-media-api-contract.md` (фиксация контракта из документации). Кода приложения не трогаем. + +> Не TDD — это сбор фактов из документации. Цель — получить максимально точный контракт медийного API, на который лягут Задачи 3–6. Живьём НЕ проверяем (песочницы нет; живая сверка — Задача 9 при go-live). + +- [ ] **Шаг 1.** Из **актуальной официальной документации** Яндекс.Директ API v5 (`yandex.ru/dev/direct/doc`) выписать точные структуры запросов/ответов: + - `campaigns.add` c `CpmBannerCampaign` (стратегия сети `CP_MAXIMUM_IMPRESSIONS` → `CpMaximumImpressions {AverageCpm, SpendLimit, StartDate, EndDate, AutoContinue}`; `Search {BiddingStrategyType: SERVING_OFF}`; `FrequencyCap {Impressions, PeriodDays}`). Точные обязательные поля, единицы (микросы vs рубли), допустимые enum сети. + - `adgroups.add` c `CpmBannerKeywordsAdGroup` (`Autotargeting.State: OFF`, `Keywords: []`, точное имя условия ретаргетинга + поля). + - Как сегмент Аудиторий связывается с медийной группой (`RetargetingLists.add` → `RetargetingListId`; `audiencetargets.add` → `AudienceTargetId`; что именно принимает `CpmBannerKeywordsAdGroup`). + - `adimages` upload (медийный формат/версия эндпоинта) + `ads.add` c `CpmBannerAd` (`Creative {Type: IMAGE, ...}`, `Href`). Точное имя хэша (`AdImageHash` vs `ImageHash`) и допустимые размеры медийных креативов. + - Отчёт по показам: `CAMPAIGN_PERFORMANCE_REPORT` — поля `Impressions`/`Cost`, тип отчёта, async/TSV, единица `Cost`. +- [ ] **Шаг 2.** Записать в findings-файл: точные JSON-контракты (запрос+ответ по докам), список принятых размеров баннеров, обязательные поля, где микросы, формат отчёта по показам. Это — источник истины для Задач 3–6. +- [ ] **Шаг 3.** Отдельным разделом «⚠️ Проверить при go-live» перечислить всё, что из документации неоднозначно (спорные имена enum/полей, единицы) — эти места проверяем живьём в Задаче 9. + +**Verify:** findings-файл содержит по каждому методу (campaigns.add / adgroups.add / audience-привязка / ads.add / отчёт) точную структуру из документации + список «проверить при go-live». + +--- + +## Task 2: Config — параметры медийной стратегии + рубильник + +**Files:** Modify `app/config/services.php`; Test `app/tests/Unit/Advertising/YandexDirectConfigTest.php`. + +- [ ] **Шаг 1: Тест.** Дополнить `YandexDirectConfigTest`: + +```php +it('exposes cpm media defaults and keeps switch off', function () { + expect(config('services.yandex_direct.enabled'))->toBeFalse() + ->and(config('services.yandex_direct.region_ids'))->toBe([225]) + ->and(config('services.yandex_direct.spend_limit_guard_multiplier'))->toBe(1.2); +}); +``` + +- [ ] **Шаг 2: Запуск.** `composer test -- --filter=YandexDirectConfigTest` → FAIL (нет ключа). +- [ ] **Шаг 3: Код.** В `services.yandex_direct` добавить `'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2)` (SpendLimit Яндекса = яндекс-бюджет × множитель — бэкстоп от перерасхода, не клиентская цена). `enabled` и `base_url` НЕ трогаем (остаются off/sandbox). +- [ ] **Шаг 4: Запуск.** Тест PASS. +- [ ] **Шаг 5: Commit** (по «go» контроллера). + +--- + +## Task 3: `YandexDirectClient::addCpmBannerCampaign()` + +**Files:** Modify `app/app/Services/Advertising/YandexDirectClient.php`; Test `app/tests/Unit/Advertising/YandexDirectMediaClientTest.php`. + +> Точную структуру взять из findings Задачи 1. Ниже — гипотеза по документации. + +- [ ] **Шаг 1: Тест** (Http::fake, проверяем ТЕЛО запроса и разбор ответа): + +```php +it('строит CpmBannerCampaign с CP_MAXIMUM_IMPRESSIONS, FrequencyCap и SpendLimit', function () { + Http::fake(['*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 777]]]])]); + $client = new YandexDirectClient('https://api-sandbox.direct.yandex.com', 'TESTTOKEN'); + + $id = $client->addCpmBannerCampaign( + name: 'Лидерра #5', startDate: '2026-08-01', endDate: '2026-08-14', + averageCpmMicros: 72_000_000, spendLimitMicros: 90_000_000, + frequencyImpressions: 3, frequencyPeriodDays: 14, + ); + + expect($id)->toBe(777); + Http::assertSent(function ($req) { + $b = $req->data(); + $cpm = $b['params']['Campaigns'][0]['CpmBannerCampaign']; + return $b['method'] === 'add' + && $cpm['BiddingStrategy']['Search']['BiddingStrategyType'] === 'SERVING_OFF' + && $cpm['BiddingStrategy']['Network']['BiddingStrategyType'] === 'CP_MAXIMUM_IMPRESSIONS' + && $cpm['BiddingStrategy']['Network']['CpMaximumImpressions']['AverageCpm'] === 72_000_000 + && $cpm['BiddingStrategy']['Network']['CpMaximumImpressions']['SpendLimit'] === 90_000_000 + && $cpm['FrequencyCap']['Impressions'] === 3 + && $cpm['FrequencyCap']['PeriodDays'] === 14; + }); +}); +``` + +- [ ] **Шаг 2: Запуск** → FAIL (метода нет). +- [ ] **Шаг 3: Код.** Добавить метод `addCpmBannerCampaign(string $name, string $startDate, string $endDate, int $averageCpmMicros, int $spendLimitMicros, int $frequencyImpressions, int $frequencyPeriodDays): int`, тело по контракту Задачи 1, возвращает `AddResults[0].Id`. Через приватный `call()`. +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 4: `YandexDirectClient::addCpmBannerAdGroup()` — группа с условием = сегмент, автотаргетинг OFF + +**Files:** Modify `YandexDirectClient.php`; Test дополнить `YandexDirectMediaClientTest.php`. + +- [ ] **Шаг 1: Тест** — проверить, что группа создаётся с `CpmBannerKeywordsAdGroup`, `Autotargeting.State=OFF`, `Keywords=[]`, `RetargetingCondition` с нашим `AudienceTargetId`, регионы из конфига. (Точные имена — из Задачи 1.) +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** `addCpmBannerAdGroup(int $campaignId, string $name, array $regionIds, int $audienceTargetId): int`. +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 5: Привязка сегмента — ретаргетинг/`AudienceTarget` для медийной группы + +**Files:** Modify `YandexDirectClient.php`; Test дополнить. + +> Задача 1 покажет: используется ли существующий `addRetargetingList` + `AudienceTargets.add` (получить `AudienceTargetId`), или условие вешается прямо в группе. Реализовать по факту. + +- [ ] **Шаг 1: Тест** — метод возвращает `AudienceTargetId`/`RetargetingListId`, который принимает группа (Задача 4). +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** При необходимости `addMediaAudienceTarget(...)`/переиспользовать `addRetargetingList`. Без клик-ставки `ContextBid` (медийная не по кликам). +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 6: `addCpmBannerAd()` — медийное объявление по `CreativeId` + +**Files:** Modify `YandexDirectClient.php`; Test дополнить. + +> 🔴 **Уточнено Задачей 1:** медийное объявление — только `CpmBannerAdBuilderAd {Creative {CreativeId}, Href}`, НЕ image-hash. Загрузка баннера картинкой через API невозможна (`creatives.add` умеет только видеодополнения); `CreativeId` берётся из веб-конструктора Яндекса. Метод клиента принимает `creativeId`, а не hash. **Откуда портал берёт `CreativeId` — решение владельца (полу-ручной шаг), фиксируется до этой задачи.** + +- [ ] **Шаг 1: Тест** — `addCpmBannerAd(int $adGroupId, int $creativeId, string $href): int` шлёт `CpmBannerAdBuilderAd {Creative {CreativeId}, Href}`, возвращает Id (Http::fake). +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** Метод по контракту §4 findings. Старые `uploadAdImage(v501)`/`addTextAd` — пометить на удаление (Задача 8), если не нужны. +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +> Если владелец выберет альтернативу загрузки баннеров (HTML5 и т.п.) — уточнить метод перед этой задачей. + +--- + +## Task 7: `CampaignLauncher::launch()` — переписать под показы (деньги + цепочка) + +**Files:** Modify `app/app/Services/Advertising/CampaignLauncher.php`; Test `app/tests/Feature/Advertising/CampaignLauncherTest.php`. + +> **Источник наценки — `ad_settings.ad_margin_percent` (дефолт 40), модель «клиент × (1 − наценка/100)»** — ТА ЖЕ, что в `CampaignImpressionCharger` (Часть 6), НЕ `AdMarkup`-делением `÷(1+markup 30%)`. Иначе разойдутся запуск и списание. `AdMarkup` в `launch()` больше не используем. +> +> **Объявление — ОДНО** `CpmBannerAdBuilderAd` с `ad_campaigns.yandex_creative_id` (адаптивный креатив покрывает все размеры). Строим из баннеров `ad_campaign_banners` только проверку «есть включённые и утверждённые»; сам креатив — один номер с кампании. Если `yandex_creative_id` пуст → бросить понятную ошибку «у кампании не указан номер креатива Яндекса — оператору оформить креатив и вписать номер» (не падать 500). + +- [ ] **Шаг 1: Тест** (Http::fake всей цепочки Аудитории+Директа) — проверить money-инварианты и порядок вызовов: + - аудитория < 100 → `AudienceTooSmallException` (как сейчас); + - нет `yandex_creative_id` → понятное исключение (не запускаем); + - `AverageCpm` в Директ = `effectiveCpm() × (1 − ad_margin_percent/100) × 1e6` (микросы, bcmath) — т.е. **яндекс-цена, не клиентская**; + - `SpendLimit` = яндекс-бюджет (`estimated_impressions/1000 × яндекс_cpm`) × `spend_limit_guard_multiplier`, микросы; + - `FrequencyCap` = `frequency` / `frequency_period_days`; + - `StartDate/EndDate` — из режима (manual: `run_days` от сегодня; auto: разумный дефолт); + - стратегия `CP_MAXIMUM_IMPRESSIONS` / `CpMaximumImpressions {AverageCpm, SpendLimit, StartDate, EndDate, AutoContinue}`, Search `SERVING_OFF`; + - заморозка кошелька — в КЛИЕНТСКИХ ₽ по оценке сметы (round up), идемпотентно; + - на кампании проставлены `yandex_segment_id`, `yandex_retargeting_list_id`, `yandex_campaign_id`, `yandex_ad_group_id`, `status = pending_moderation`, `launched_at`; создано ОДНО медийное объявление (`yandex_ad_id` где хранить — на кампании или отд. поле, решить в коде), `moderation_status = MODERATION`. + - **Money-leak тест:** сериализация кампании в JSON НЕ содержит `yandex_cost_rub`/`ad_margin_percent`. +- [ ] **Шаг 2: Запуск** → FAIL. +- [ ] **Шаг 3: Код.** Переписать `launch()`: наценка через `ad_settings.ad_margin_percent` (модель «минус»); убрать `AdMarkup`/`weekly_budget_rub`/`click_bid_rub`/`ContextBid`(клик); собрать медийную цепочку через новые методы клиента (Задачи 3–6): `addRetargetingList` → `addCpmBannerCampaign` → `addCpmBannerAdGroup` → `addMediaAudienceTarget` → `addCpmBannerAd(adGroupId, yandex_creative_id, href)`. Все деньги — bcmath, микросы — целые. `href` — сайт клиента (из настроек/кампании). +- [ ] **Шаг 4: Запуск** → PASS. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 8: Флоу `submit`/`launch` в контроллере + чистка легаси клик-кода + +**Files:** Modify `AdvertisingCampaignController.php`, `AdCampaign.php`; Test `CampaignSubmitTest.php` (+ endpoint-тест launch). + +- [ ] **Шаг 1: Тест.** Зафиксировать флоу: `submit` (готовность) → `queued`; фактический `launch` идёт **только при `yandex_direct.enabled=true`** (иначе 409 «Директ выключен» — как сейчас в `CampaignLauncher`). Проверить, что при `enabled=false` submit не падает и деньги не морозятся. Обновить существующие submit-тесты, если сигнатура/тексты меняются. +- [ ] **Шаг 2: Запуск** → FAIL/адаптация. +- [ ] **Шаг 3: Код.** Согласовать `submit`↔`launch`: submit оставить как «готово к запуску»; `launch` (ручной/джобом) выполняет медийную цепочку под рубильником. Убрать из `AdCampaign` fillable/casts легаси `weekly_budget_rub`, `click_bid_rub`; удалить старые методы клиента `addCampaign(TextCampaign)`/`addTextAd`/`addAudienceTarget(ContextBid)`, если не используются; удалить мёртвый `AdMarkup` клик-путь. Сверить: `getCampaignSpend` возвращает **показы** (Impressions), а не клики — поправить `FieldNames`/парсинг под отчёт показов (согласовать с `ChargeCampaignSpendJob` Части 6). +- [ ] **Шаг 4: Запуск** — все тесты рекламы зелёные. +- [ ] **Шаг 5: Commit.** + +--- + +## Task 9: Живая сверка при go-live — контролируемый пробный запуск в боевом кабинете (ОТДЕЛЬНЫЙ «go») + +**Files:** дополнить findings-файл Задачи 1. + +> Песочницы нет → живьём проверяем ТОЛЬКО в боевом кабинете, поэтому этот шаг выполняется НЕ во время разработки, а отдельным контролируемым сеансом под присмотром владельца, по его явному «go». К этому моменту весь код и `Http::fake`-тесты (Задачи 2–8) уже зелёные. + +- [ ] **Шаг 1.** По отдельному «go» владельца: переключить стенд на боевой (`base_url=api.direct.yandex.com`, рубильник ВКЛ временно), прогнать `CampaignLauncher->launch()` на ОДНОЙ тестовой кампании с крошечным сегментом и минимальным `SpendLimit`. Проверить: 200 OK на каждом методе, id проставлены, кампания создалась медийная (`Type=CPM_BANNER_CAMPAIGN`). +- [ ] **Шаг 2.** Немедленно остановить/архивировать кампанию (`suspendCampaign`) — до начала открутки показов. Деньги не тратятся, пока показов нет. Свериться: реальные имена enum/полей совпали с findings Задачи 1 (закрыть список «проверить при go-live»); поправить код при расхождении. +- [ ] **Шаг 3.** Проверить отчёт по показам (или зафиксировать, что за пробный запуск статистики нет — тогда `ChargeCampaignSpendJob` покрыт юнитом на fake-отчёте). Вернуть рубильник/`base_url` в исходное безопасное состояние до отдельного решения о полном go-live. Записать результат в findings. + +--- + +## Task 10: Финальное ревью + подготовка к выкату (без включения на бою) + +- [ ] **Шаг 1.** `superpowers:requesting-code-review` по всему diff Части 4 (money-leak, RLS не затронут, нет мёртвого клик-кода, идемпотентность заморозки). +- [ ] **Шаг 2.** Полный прогон: `composer test -- --filter=Advertising` + затронутые фронт-специи (если менялись) зелёные; `composer stan` 0. +- [ ] **Шаг 3.** Обновить хэндофф `docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-*.md`: Часть 4 готова к бою, чек-лист go-live (переключить `YANDEX_DIRECT_BASE_URL`→`api.direct.yandex.com`, `YANDEX_DIRECT_ENABLED`→true, `config:cache` под www-data, один контролируемый тестовый запуск под присмотром). +- [ ] **Шаг 4.** НЕ выкатывать, НЕ включать рубильник. Отчитаться владельцу, ждать «go». + +--- + +## 🔴 Мины (перечитать перед стартом) + +- **Боевой прод — деньги.** Весь код за рубильником `yandex_direct.enabled=false` до go-live. Песочницы нет → живая сверка (Задача 9) идёт в боевом кабинете ОДНИМ контролируемым пробным запуском под присмотром владельца, с немедленной остановкой; переключение `base_url`/рубильника — отдельным «go». Никаких реальных РАБОТАЮЩИХ кампаний без ведома владельца. +- **Наценка/`yandex_cost_rub`/`ad_margin_percent`** — НИКОГДА в клиентском JSON (тест в Задаче 7). +- **Микросы — целые** (1 ₽ = 1 000 000); все деньги bcmath, никакого float-округления в расчёте сумм. +- **Точные поля медийного API** — из Задачи 1 (сверка по документации), окончательно подтверждаются живьём в Задаче 9 при go-live. Спорные места помечены «проверить при go-live». +- **Наценка 30% vs 40%:** старый `CampaignLauncher` брал `markup_percent` 30%; новый флоу — `ad_margin_percent` 40% (Часть 5d). Не оставить два источника наценки. +- **Тройная страховка перерасхода:** `FrequencyCap` + фикс. размер сегмента + джоб-стоп по оплаченным показам (Часть 6) + `SpendLimit` в Директе. +- **Windows worktree:** свой `composer install`; larastan в worktree — `--error-format=json` (см. память `feedback-worktree-laravel-windows`). Коммиты — `LEFTHOOK_EXCLUDE=larastan`, paren-free, по «go». + +--- + +## Self-review (проведён при написании плана) + +- **Покрытие дизайна §5/§6:** цепочка Директа (сегмент→ретаргетинг→медийная→группа→объявления→заморозка) — Задачи 3–7; деньги/наценка — Задача 7; чистка клик-полей — Задача 8; отчёт по показам — Задача 8; сверка API по документации — Задача 1; живая сверка при go-live — Задача 9. ✅ +- **Placeholder-scan:** точные поля API вынесены в Задачу 1 (сверка по документации) — это не placeholder, а сбор контракта; Задачи 3–6 ссылаются на него, окончательное подтверждение живьём — Задача 9. ✅ +- **Type-consistency:** методы клиента (`addCpmBannerCampaign`/`addCpmBannerAdGroup`/`addMediaAudienceTarget`/`addCpmBannerAd`) названы единообразно и используются в Задаче 7. ✅ +- **Открытый риск (решён 26.07.2026):** песочницы для полного доступа нет → живая сверка перенесена в Задачу 9 (контролируемый пробный запуск в боевом кабинете при go-live, под присмотром владельца). Разработка и все `Http::fake`-тесты (Задачи 2–8) от Яндекса не зависят. diff --git a/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5b-master-ui.md b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5b-master-ui.md new file mode 100644 index 00000000..8bc48128 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5b-master-ui.md @@ -0,0 +1,404 @@ +# План — Реклама «за показы», Часть 5b: мастер (UI) + бэкенд store/submit + скрытие маржи + +> **Для агентов:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`. Шаги — чекбоксами. + +**Goal:** Переделать клиентский мастер кампании `CampaignWizard.vue` с модели «за клики» на «за показы» +(окно дней → частота+живая смета → 1 картинка+превью+утверждение → проверка+отправка), поправить +бэкенд `store/update` (клик-поля → частота/бюджет показов) + добавить безопасную «отправку заявки» +при закрытом Директе, и 🔴 спрятать `yandex_cost_rub` (маржу) от клиента. + +**Архитектура:** Directа сейчас нет (заявка на полный доступ подана 26.07, ждём). Поэтому терминальное +действие мастера — не реальный запуск в Директ (это Часть 4), а перевод кампании в статус `queued` +(«готова к запуску, ждёт оператора») через новый endpoint `submit`. Реальный запуск+заморозку денег +доделает Часть 4, когда Яндекс откроет доступ. Всё локально, на бой не выкатываем. + +**Tech Stack:** Laravel 13 / Pest 4 (бэкенд), Vue 3 + Vuetify 3 + палитра Forest / Vitest (фронт). +Навык **Frontend Design** обязателен для Task 3 (UI). + +**Контекст ветки:** `feat/reklama-yandex-pokazy`, HEAD `449c6c5e`. Готово Ч.1–3, 5a. Тестовая БД +`liderra_testing` ОБЩАЯ — перед бэкенд-тестами: `DB_DATABASE=liderra_testing php artisan migrate --force`. +Фронт-тесты: `npx vitest run tests/Frontend/`. Коммит — контроллер через escape по «го». + +--- + +## Что уже есть (не переделывать) + +- Модель `AdCampaign` — поля `frequency`, `frequency_period_days`, `estimated_impressions`, + `paid_impressions`, `delivered_impressions`, `budget_rub`, `yandex_cost_rub`, `charged_client_rub`, + `banners_approved_at` уже в `$fillable`/`casts()`. `status` — `string(24)` без CHECK. +- Endpoint `GET .../audience-size?days=&frequency=` уже отдаёт `impressions/cpm_rub/cost_rub` (Ч.2). +- Endpoint'ы баннеров (`banner-source`/`banners`/`banners/{id}/preview`/`banners/approve`) — Ч.3b-2. +- `api/advertising.ts`: `fetchAudienceSize(id,days,frequency)`, `uploadBannerSource`, `fetchBanners`, + `approveBanners`, `bannerPreviewUrl` + типы `CampaignBanner`/`CampaignBannerSet` — Ч.5a. +- Админ-контроллер читает `yandex_cost_rub` через атрибут и строит явные массивы → `$hidden` на модели + его НЕ ломает. + +--- + +## Файловая карта + +- Изменить: `app/app/Models/AdCampaign.php` — `$hidden` + `STATUS_QUEUED`. +- Изменить: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` — `store/update` под показы, + новый `submit()`. +- Изменить: `app/routes/web.php` — маршрут `POST .../submit`. +- Изменить: `app/resources/js/api/advertising.ts` — типы `CampaignCreate`/`Campaign` под показы, + функция `submitCampaign`. +- Изменить: `app/resources/js/components/advertising/CampaignWizard.vue` — переделка шагов. +- Изменить: `app/resources/js/components/advertising/CampaignList.vue` — метка статуса `queued` (мелко). +- Тесты: `app/tests/Feature/Advertising/CampaignPokazyStoreTest.php`, + `app/tests/Feature/Advertising/CampaignSubmitTest.php`, + `app/tests/Feature/Advertising/CampaignMarginHiddenTest.php`, + `app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts`. + +--- + +## Task 1: Модель — скрытие маржи + статус `queued` (backend, TDD) + +**Files:** Изменить `app/app/Models/AdCampaign.php`; Тест `app/tests/Feature/Advertising/CampaignMarginHiddenTest.php`. + +- [ ] **Шаг 1. Тест (падает):** клиентская выдача НЕ содержит `yandex_cost_rub`. + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'X', 'audience_days' => 10, + 'budget_rub' => '500.00', 'yandex_cost_rub' => '380.00', 'charged_client_rub' => '500.00', + ]); + $res = actingAs($user)->getJson("/api/advertising/campaigns/{$c->id}"); + $res->assertOk(); + $json = $res->json('campaign'); + expect($json)->not->toHaveKey('yandex_cost_rub'); + expect($json)->toHaveKey('charged_client_rub'); // деньги клиента — показываем +}); + +it('модель отдаёт yandex_cost_rub при явном доступе (для админки)', function () { + $c = new AdCampaign(['yandex_cost_rub' => '380.00']); + expect((string) $c->yandex_cost_rub)->toBe('380.00'); // атрибут доступен, скрыта только сериализация +}); +``` + +- [ ] **Шаг 2. Запуск — падает** (`yandex_cost_rub` пока сериализуется). Run: + `DB_DATABASE=liderra_testing php artisan migrate --force && DB_DATABASE=liderra_testing php artisan test --filter=CampaignMarginHiddenTest` + +- [ ] **Шаг 3. Правка модели** `app/app/Models/AdCampaign.php`: + - добавить константу рядом с прочими STATUS_*: `public const STATUS_QUEUED = 'queued';` + - добавить свойство: +```php + /** yandex_cost_rub — наш расход у Яндекса (основа маржи), клиенту НИКОГДА не показываем. + * Только атрибут-доступ (админка строит явные массивы), сериализация — скрыта (Ч.5b, спека §3). */ + protected $hidden = ['yandex_cost_rub']; +``` + +- [ ] **Шаг 4. Запуск — зелёно.** Тот же filter. + +- [ ] **Шаг 5. Commit** (контроллер, по «го»). + +--- + +## Task 2: store/update под показы + endpoint submit (backend, TDD) + +**Files:** Изменить `AdvertisingCampaignController.php`, `routes/web.php`; +Тесты `CampaignPokazyStoreTest.php`, `CampaignSubmitTest.php`. + +**store/update:** принимать поля показов, убрать обязательность клик-полей. `weekly_budget_rub` больше +НЕ required (колонка nullable уже). Клик-поля (`click_bid_rub`, `daily_budget_rub`) из валидации убрать +(YAGNI, спека §11 — «не оставляем клик-поля»). + +- [ ] **Шаг 1. Тест store (падает):** + +```php +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $res = actingAs($user)->postJson('/api/advertising/campaigns', [ + 'name' => 'Показы 1', 'audience_days' => 14, + ]); + $res->assertCreated(); + $c = AdCampaign::firstWhere('name', 'Показы 1'); + expect($c)->not->toBeNull(); + expect($c->status)->toBe('draft'); +}); + +it('патчит частоту, показы и бюджет показов', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create(['tenant_id' => $tenant->id, 'name' => 'X', 'audience_days' => 10]); + $res = actingAs($user)->patchJson("/api/advertising/campaigns/{$c->id}", [ + 'frequency' => 5, 'frequency_period_days' => 30, + 'estimated_impressions' => 2500, 'budget_rub' => '300.00', + ]); + $res->assertOk(); + $c->refresh(); + expect($c->frequency)->toBe(5); + expect($c->estimated_impressions)->toBe(2500); + expect((string) $c->budget_rub)->toBe('300.00'); +}); +``` + +- [ ] **Шаг 2. Запуск — падает.** + +- [ ] **Шаг 3. Правка `store()`** — заменить блок `$request->validate([...])` и `AdCampaign::create([...])`: + +```php + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'audience_days' => ['required', 'integer', 'min:1', 'max:90'], + 'use_uploaded_list' => ['boolean'], + 'frequency' => ['nullable', 'integer', 'min:1', 'max:1000'], + 'frequency_period_days' => ['nullable', 'integer', 'min:1', 'max:90'], + 'estimated_impressions' => ['nullable', 'integer', 'min:0'], + 'budget_rub' => ['nullable', 'numeric', 'min:0'], + ]); + + $campaign = AdCampaign::create([ + 'tenant_id' => $tenantId, + 'status' => AdCampaign::STATUS_DRAFT, + 'name' => $data['name'], + 'audience_days' => $data['audience_days'], + 'use_uploaded_list' => $data['use_uploaded_list'] ?? false, + 'frequency' => $data['frequency'] ?? null, + 'frequency_period_days' => $data['frequency_period_days'] ?? null, + 'estimated_impressions' => $data['estimated_impressions'] ?? null, + 'budget_rub' => $data['budget_rub'] ?? null, + ]); +``` + +- [ ] **Шаг 4. Правка `update()`** — заменить `$request->validate([...])`: + +```php + $data = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'audience_days' => ['sometimes', 'required', 'integer', 'min:1', 'max:90'], + 'use_uploaded_list' => ['sometimes', 'boolean'], + 'frequency' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:1000'], + 'frequency_period_days' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:90'], + 'estimated_impressions' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'budget_rub' => ['sometimes', 'nullable', 'numeric', 'min:0'], + ]); +``` + +- [ ] **Шаг 5. `index()` — отдать поля показов** (строка 45): заменить массив колонок на + `['id', 'name', 'status', 'audience_days', 'frequency', 'estimated_impressions', 'budget_rub', 'launched_at']` + (убрать `weekly_budget_rub`). + +- [ ] **Шаг 6. Тест submit (падает):** + +```php + $tenantId, 'name' => 'Готовая', 'audience_days' => 10, + 'frequency' => 5, 'estimated_impressions' => 2500, 'budget_rub' => '300.00', + 'banners_approved_at' => now(), + ]); + AdCampaignBanner::create(['tenant_id' => $tenantId, 'campaign_id' => $c->id, + 'width' => 300, 'height' => 250, 'path' => 'x', 'bytes' => 1]); + return $c; +} + +it('переводит готовую кампанию в статус queued', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = makeReadyCampaign($tenant->id); + $res = actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit"); + $res->assertOk()->assertJson(['status' => 'queued']); + expect($c->fresh()->status)->toBe('queued'); +}); + +it('не отправляет без утверждённых баннеров', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $c = AdCampaign::create(['tenant_id' => $tenant->id, 'name' => 'Пустая', + 'audience_days' => 10, 'frequency' => 5, 'estimated_impressions' => 2500, 'budget_rub' => '300.00']); + actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit")->assertStatus(422); + expect($c->fresh()->status)->toBe('draft'); +}); + +it('чужую кампанию не отправляет (404)', function () { + $t1 = Tenant::factory()->create(); $t2 = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $t1->id]); + $c = makeReadyCampaign($t2->id); + actingAs($user)->postJson("/api/advertising/campaigns/{$c->id}/submit")->assertNotFound(); +}); +``` + +- [ ] **Шаг 7. Метод `submit()`** в контроллере (после `launch()`): + +```php + /** + * Ч.5b — «отправить заявку на запуск». Пока Директ закрыт (заявка на доступ на рассмотрении), + * мастер не запускает кампанию в Директе (это Часть 4), а переводит готовую кампанию в статус + * `queued` — «готова к запуску, ждёт оператора». Требует: утверждённые баннеры + заданную частоту. + * Реальный запуск/заморозку денег доделает Часть 4. + */ + public function submit(Request $request, int $id): JsonResponse + { + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + if ($campaign->banners_approved_at === null) { + return response()->json(['message' => 'Сначала утвердите баннеры.'], 422); + } + if ($campaign->frequency === null || $campaign->estimated_impressions === null) { + return response()->json(['message' => 'Не заданы частота и смета показов.'], 422); + } + + $campaign->update(['status' => AdCampaign::STATUS_QUEUED]); + + return response()->json(['status' => $campaign->fresh()->status]); + } +``` + +- [ ] **Шаг 8. Маршрут** в `routes/web.php` (в группе `/api/advertising`, рядом с `launch`): + `Route::post('/advertising/campaigns/{id}/submit', [AdvertisingCampaignController::class, 'submit']);` + (сверить точный синтаксис группы по соседним строкам — middleware `auth:sanctum`+tenant уже на группе.) + +- [ ] **Шаг 9. Запуск всех бэкенд-тестов — зелёно:** + `DB_DATABASE=liderra_testing php artisan test --filter="CampaignPokazyStore|CampaignSubmit|CampaignMarginHidden"` + +- [ ] **Шаг 10. Commit.** + +--- + +## Task 3: advertising.ts под показы + submitCampaign (frontend, TDD) + +**Files:** Изменить `app/resources/js/api/advertising.ts`; +Тест `app/tests/Frontend/advertising-api-pokazy.spec.ts` (дополнить существующий из Ч.5a). + +- [ ] **Шаг 1. Тест (падает):** `submitCampaign(1)` → POST `/api/advertising/campaigns/1/submit`, + возвращает `{status:'queued'}` (стиль как в `advertising-api-pokazy.spec.ts` — мок `apiClient`). + +```ts +it('submitCampaign postит на submit и возвращает status', async () => { + const post = vi.mocked(apiClient.post).mockResolvedValue({ data: { status: 'queued' } } as any); + const r = await submitCampaign(1); + expect(post).toHaveBeenCalledWith('/api/advertising/campaigns/1/submit'); + expect(r.status).toBe('queued'); +}); +``` + +- [ ] **Шаг 2. Запуск — падает.** + +- [ ] **Шаг 3. Правка `advertising.ts`:** + - `CampaignStatus` — добавить `'queued'` в union. + - `Campaign` — добавить `frequency?: number; estimated_impressions?: number; budget_rub?: string;`; + убрать `click_bid_rub`, `weekly_budget_rub` сделать опциональным (legacy). НЕ добавлять `yandex_cost_rub`. + - `CampaignCreate` — убрать `click_bid_rub`, `weekly_budget_rub`, `daily_budget_rub`; добавить + `frequency?: number; frequency_period_days?: number; estimated_impressions?: number; budget_rub?: string;` + (обязательны только `name`, `audience_days`, `use_uploaded_list`). + - добавить функцию: +```ts +/** POST /api/advertising/campaigns/{id}/submit — отправить готовую кампанию на запуск (статус queued). */ +export async function submitCampaign(id: number): Promise<{ status: CampaignStatus }> { + await ensureCsrfCookie(); + const { data } = await apiClient.post<{ status: CampaignStatus }>(`/api/advertising/campaigns/${id}/submit`); + return data; +} +``` + +- [ ] **Шаг 4. Запуск — зелёно** (и существующий `advertising-api.spec.ts`/`advertising-api-pokazy.spec.ts` не сломаны): + `npx vitest run tests/Frontend/advertising-api.spec.ts tests/Frontend/advertising-api-pokazy.spec.ts` + +- [ ] **Шаг 5. Commit.** + +--- + +## Task 4: CampaignWizard.vue — переделка под показы (frontend, Frontend Design + TDD) + +> **СНАЧАЛА** открыть навык `frontend-design` (UI-решения строго в дизайн-системе портала: +> Vuetify 3 + палитра Forest, mdi-иконки; НЕ новая айдентика). Мастер — это система портала. + +**4 шага под показы (заменяют текущие клик-шаги):** +1. **Кому показываем** — `audience_days` (свой слайдер/число) + живой размер аудитории + тумблер «мой + список» + загрузка списка. (Логика уже есть — сохранить как есть, тексты не про клики.) +2. **Как часто показывать** — слайдер/поле `frequency` (1..1000, дефолт напр. 5) + **живая смета**: + `fetchAudienceSize(draftId, audience_days, frequency)` → показывает «Показов: ~impressions», + «Цена: 120 ₽ за 1000 показов», «Итого: cost_rub ₽». 🔴 НИКАКОЙ маржи/цены Яндекса. При смене + частоты — debounce, как у дней. При уходе с шага — `patchCampaign(draftId, {frequency, + frequency_period_days: audience_days, estimated_impressions: impressions, budget_rub: cost_rub})`. +3. **Баннер** — загрузка ОДНОЙ картинки (`uploadBannerSource`) → галерея-превью всех размеров + (`fetchBanners` + `bannerPreviewUrl`) → кнопка «Утвердить баннеры» (`approveBanners`). До утверждения + на шаг 4 не пускаем. Показать предупреждение про узкие форматы (кроп может срезать логотип — спека §4). +4. **Проверка и отправка** — сводка: аудитория ~size, частота N, показов ~impressions, цена 120 ₽/1000, + итого cost_rub ₽. Галочка-согласие (Р24) — ТОЛЬКО если `use_uploaded_list`. Кнопка + **«Отправить заявку»** → `submitCampaign(draftId)` → успех эмитит `launched`. Текст успеха: + «Заявка отправлена — запустим вашу рекламу в ближайшее время». (Пока Директ закрыт; реальный запуск — Ч.4.) + +**Убрать полностью:** шаг «Объявления»/`CreativeForm` (текстовые креативы клик-модели), поля +`weekly_budget_rub`/`click_bid_rub` и «Цена за клик», строку «Сайт» из сводки, импорты +`createCampaign`(оставить)/`launchCampaign`(заменить на `submitCampaign`)/`AdCreative`/`CreativeForm`. + +**Форма (reactive):** `{ name, audience_days, use_uploaded_list, frequency }` (+ храним последнюю смету +`audienceSize` с impressions/cost_rub). Дефолт `frequency = 5`. + +- [ ] **Шаг 1. Тест (падает)** `app/tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts` — по образцу + существующего `advertising-campaign-wizard.spec.ts` (мок функций `api/advertising`). Проверить: + - на шаге 2 при заданной частоте зовётся `fetchAudienceSize(id, days, frequency)` и в разметке видно + «120 ₽ за 1000 показов» и сумму `cost_rub`; НЕТ текста «Цена за клик» и НЕТ `yandex_cost`. + - шаг 3: `uploadBannerSource` → `fetchBanners` → превью рендерятся; «Утвердить» зовёт `approveBanners`. + - шаг 4: кнопка «Отправить заявку» зовёт `submitCampaign(draftId)` и эмитит `launched`. + - согласие требуется только при `use_uploaded_list=true`. + +- [ ] **Шаг 2. Запуск — падает.** `npx vitest run tests/Frontend/advertising-campaign-wizard-pokazy.spec.ts` + +- [ ] **Шаг 3. Переписать `CampaignWizard.vue`** по описанию выше (Vuetify 3 + Forest, `data-testid` + как в текущем файле для стабильности тестов; `num`-класс для сумм; FAB-отступ футера сохранить). + Полный код компонента — реализатор пишет, сверяясь с текущим файлом (шаг 1 аудитории и загрузка + списка переносятся почти без изменений; шаги 2–4 новые). Смету НЕ считать на фронте (берём `cost_rub` + из ответа сервера — Р37). + +- [ ] **Шаг 4. Запуск — зелёно.** Тот же spec + прогнать существующий `advertising-campaign-wizard.spec.ts`: + если он завязан на старые (клик) шаги — переписать/удалить его под новую модель (не оставлять красным). + +- [ ] **Шаг 5. Commit.** + +--- + +## Task 5: CampaignList.vue — метка статуса queued (frontend, мелко) + +**Files:** Изменить `app/resources/js/components/advertising/CampaignList.vue`. + +- [ ] **Шаг 1.** Найти маппинг статусов (chip/label). Добавить `queued` → «Готова к запуску» + (цвет — нейтральный/info по палитре). Если список показывает бюджет/клики — заменить на показы + (`estimated_impressions`/`budget_rub`). Если есть Vitest на список — обновить; иначе добавить краткий. + +- [ ] **Шаг 2. Запуск фронт-тестов раздела — зелёно.** + +- [ ] **Шаг 3. Commit.** + +--- + +## Готовность (self-review контроллера) + +- [ ] Клиент нигде не видит `yandex_cost_rub`/маржу/«цену за клик» (тест + глазами по diff). +- [ ] Бэкенд: `store/update` принимают частоту/бюджет; `submit` переводит в `queued` с guard'ами; `index` + отдаёт поля показов. Все Pest зелёные. +- [ ] Фронт: мастер 4 шага показов; смета из сервера; баннеры превью+утверждение; отправка → queued. + Vitest зелёный; старые клик-тесты не оставлены красными. +- [ ] `db/CHANGELOG_schema.md` — правки НЕ требуются (миграций нет; статус `queued` — без DDL). Если + реализатор всё же трогает схему — запись обязательна + `rls-reviewer`. +- [ ] Ничего на бой/в gitea без «го». + +**Дальше:** Часть 6 (отчёты по показам, списание по факту, админ-маржа) — после Ч.5b. Часть 4 (реальный +запуск в Директ) — когда Яндекс одобрит полный доступ (заявка подана 26.07). diff --git a/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5c-master-pravki.md b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5c-master-pravki.md new file mode 100644 index 00000000..b41df0b0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5c-master-pravki.md @@ -0,0 +1,419 @@ +# План Части 5c — правки мастера показов (клиент грузит баннеры сам) + +> **Для исполнителя-агента:** ОБЯЗАТЕЛЬНЫЙ СУБ-СКИЛ — `superpowers:subagent-driven-development`. +> Шаги отмечены чекбоксами. TDD: сначала падающий тест, потом код. Субагент НЕ коммитит. + +**Goal:** Переделать шаг «Баннеры» на модель «клиент грузит готовый баннер под каждый размер» (без обрезки/ИИ), +плюс мелкие правки мастера по фидбеку владельца (цена настраиваема, «Далее» убрать на последнем шаге, лимиты +загрузки, тексты). + +**Architecture:** `BannerSizes` (15 форматов) остаётся списком «нужных размеров». `ad_campaign_banners` теперь +хранит клиентские файлы (по одному на размер) + флаг `included` («в показ»). Валидация: точное совпадение +`width×height`, формат JPG/PNG/GIF, вес ≤ капа. `CampaignBannerService::generate`/`BannerGenerator` из +клиентского потока уходят (файлы не удаляем — просто не зовём). Наценка/`yandex_cost_rub` клиенту не видна. + +**Tech Stack:** Laravel 13 / PHP 8.3 / PostgreSQL 16 (RLS) / Pest 4; Vue 3 + Vuetify 3 / Vitest / TS. + +**Источник истины по решению:** `docs/superpowers/2026-07-26-HANDOFF-reklama-pokazy-master-pravki.md` +(секция «✅ РЕШЕНО 26.07»). Владелец: «просим все картинки — пусть делает клиент сам; мы говорим размер и объём». + +--- + +## Контракт API (после переделки) + +- `GET /api/advertising/campaigns/{id}/banners` → + ```json + { + "approved_at": null, + "max_bytes": 153600, + "formats": ["jpg","png","gif"], + "slots": [ + {"width":728,"height":90,"uploaded":false,"banner_id":null,"bytes":null,"included":true, + "preview_url":null} + ] + } + ``` + `slots` — ВСЕ 15 форматов из `BannerSizes`, отсортированы; `included`/`preview_url`/`bytes` заполнены для + загруженных. +- `POST /api/advertising/campaigns/{id}/banners` (multipart: `width`,`height`,`file`) → 201 + `{ "slot": {…как элемент slots…} }`. Валидация: размер картинки ровно `width×height`, формат в списке, вес ≤ + `max_bytes`. Повторная загрузка того же размера — заменяет файл/строку. Меняет баннеры ⇒ сбрасывает + `banners_approved_at = null`. +- `PATCH /api/advertising/campaigns/{id}/banners/{bannerId}` (`{included:bool}`) → `{ "slot": {…} }`. +- `DELETE /api/advertising/campaigns/{id}/banners/{bannerId}` → 204. Сбрасывает `banners_approved_at`. +- `POST /api/advertising/campaigns/{id}/banners/approve` → 200 `{approved_at}`; 422 если нет ни одного + загруженного+`included`. +- Админ CPM: `GET /api/admin/advertising/settings` → `{client_cpm_rub}`; `PUT` (`{client_cpm_rub}`, numeric>0). + +Константы (новый `App\Services\Advertising\BannerUploadPolicy`): `MAX_BYTES = 153600` (150 КБ, наш текущий кап, +сверка с пофрматными лимитами Яндекса — Часть 4), `FORMATS = ['jpg','jpeg','png','gif']`. + +--- + +## Backend + +### Task 1: Миграция — `included` + GRANT UPDATE + +**Files:** +- Create: `app/database/migrations/2026_07_26_100400_add_included_to_ad_campaign_banners.php` +- Modify: `db/CHANGELOG_schema.md` (новая запись) + +- [ ] **Step 1: Написать миграцию** + +```php +boolean('included')->default(true)->after('bytes'); + }); + // Клиент теперь меняет included и перезаливает баннер — нужен UPDATE (раньше был только SELECT/INSERT/DELETE). + DB::statement('GRANT UPDATE ON ad_campaign_banners TO crm_app_user'); + } + + public function down(): void + { + Schema::table('ad_campaign_banners', fn (Blueprint $t) => $t->dropColumn('included')); + // GRANT UPDATE намеренно не отзываем в down (безопасно; таблица tenant-isolated). + } +}; +``` + +- [ ] **Step 2: Прогнать миграцию на тестовой БД** + +Run: `DB_DATABASE=liderra_testing php artisan migrate --force` +Expected: `Migrated: ...add_included_to_ad_campaign_banners` + +- [ ] **Step 3: Запись в CHANGELOG_schema.md** — версия +1, строка: «ad_campaign_banners += included BOOLEAN + NOT NULL DEFAULT true (частичное утверждение «в показ»); GRANT UPDATE crm_app_user». Дата 26.07.2026. + +- [ ] **Step 4: rls-reviewer** — контроллер вызывает агента `rls-reviewer` на изменённой миграции. Ждём PASS + (проверка: tenant_id есть, policy на месте, GRANT согласован, CHANGELOG заполнен). + +### Task 2: Модель `AdCampaignBanner` — `included` + +**Files:** Modify: `app/app/Models/AdCampaignBanner.php` + +- [ ] **Step 1: Тест** `app/tests/Feature/Advertising/BannerIncludedModelTest.php` + +```php + 0]); + expect($b->included)->toBeFalse(); +}); +``` + +- [ ] **Step 2:** Добавить `'included'` в `$fillable`, `'included' => 'boolean'` в `casts()`. +- [ ] **Step 3:** `php artisan test --filter=BannerIncludedModelTest` → PASS. + +### Task 3: `BannerUploadPolicy` (константы вес/форматы) + +**Files:** Create: `app/app/Services/Advertising/BannerUploadPolicy.php`; Test: +`app/tests/Unit/Advertising/BannerUploadPolicyTest.php` + +- [ ] **Step 1: Тест** — `MAX_BYTES === 153600`, `FORMATS` содержит `jpg,png,gif`, метод + `humanMaxSize(): string` возвращает `'150 КБ'`. +- [ ] **Step 2: Реализация** + +```php +final class BannerUploadPolicy +{ + public const MAX_BYTES = 153600; // 150 КБ — наш текущий кап (сверка с Яндексом — Часть 4) + /** @var list */ + public const FORMATS = ['jpg', 'jpeg', 'png', 'gif']; + + public static function humanMaxSize(): string + { + return (int) round(self::MAX_BYTES / 1024).' КБ'; + } +} +``` + +- [ ] **Step 3:** тест PASS. + +### Task 4: `uploadBanner` (per-size) вместо `uploadBannerSource` + +**Files:** Modify: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php`, +`app/routes/web.php:394`; Test: `app/tests/Feature/Advertising/BannerUploadPerSizeTest.php` + +- [ ] **Step 1: Тесты** (Pest, `DatabaseTransactions`, `Storage::fake('local')`, `actingAs` клиента тенанта; + использовать существующий helper авторизации из соседних banner-тестов — посмотреть + `AdvertisingCampaignEndpointTest.php`): + - точная картинка 728×90 (сгенерить через GD `imagecreatetruecolor(728,90)`→png) → 201, `slot.uploaded===true`, + строка в БД с `width=728,height=90`, файл на диске. + - картинка НЕ того размера (800×100 под слот 728×90) → 422, сообщение содержит «ровно 728×90». + - вес > `MAX_BYTES` → 422 (сообщение про «объём»/размер файла). + - не-картинка (txt) → 422. + - повторная загрузка того же размера → заменяет (в БД по-прежнему одна строка на размер), `banners_approved_at` + сброшен в null. + +- [ ] **Step 2: Реализация метода** (заменяет `uploadBannerSource`): + +```php +public function uploadBanner(Request $request, int $id): JsonResponse +{ + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + $validated = $request->validate([ + 'width' => ['required', 'integer'], + 'height' => ['required', 'integer'], + 'file' => ['required', 'file', 'mimes:'.implode(',', BannerUploadPolicy::FORMATS), + 'max:'.(int) (BannerUploadPolicy::MAX_BYTES / 1024)], + ]); + $w = (int) $validated['width']; + $h = (int) $validated['height']; + if (! in_array([$w, $h], BannerSizes::all(), true)) { + return response()->json(['message' => 'Неизвестный размер баннера.'], 422); + } + + $file = $request->file('file'); + [$imgW, $imgH] = getimagesize($file->getRealPath() ?: $file->getPathname()) ?: [0, 0]; + if ($imgW !== $w || $imgH !== $h) { + return response()->json([ + 'message' => "Нужен ровно {$w}×{$h}. Вы загрузили {$imgW}×{$imgH}.", + ], 422); + } + + $binary = (string) file_get_contents($file->getRealPath() ?: $file->getPathname()); + $path = "ad-banners/{$tenantId}/{$campaign->id}/{$w}x{$h}.".$file->getClientOriginalExtension(); + + $disk = Storage::disk('local'); + $existing = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id)->where('width', $w)->where('height', $h)->first(); + if ($existing) { + $disk->delete($existing->path); + } + $disk->put($path, $binary); + + $banner = AdCampaignBanner::updateOrCreate( + ['tenant_id' => $tenantId, 'campaign_id' => $campaign->id, 'width' => $w, 'height' => $h], + ['path' => $path, 'bytes' => strlen($binary), 'included' => $existing->included ?? true], + ); + $campaign->update(['banners_approved_at' => null]); + + return response()->json(['slot' => $this->bannerSlot($campaign, $w, $h, $banner)], 201); +} +``` + +- [ ] **Step 3: Роут** — заменить строку 394: + `Route::post('/campaigns/{id}/banners', '...@uploadBanner')->whereNumber('id');` + (старый `banner-source` роут удалить). +- [ ] **Step 4:** тесты PASS. Удалить старый `uploadBannerSource` метод. + +### Task 5: `listBanners` → required-sizes + state; хелпер `bannerSlot`/`bannerSlots` + +**Files:** Modify: контроллер (замена `bannerList` на `bannerSlots`); Test: в `BannerUploadPerSizeTest` +добавить проверку `GET .../banners` возвращает 15 слотов, из них загруженные с `preview_url`. + +- [ ] **Step 1:** переписать `listBanners`: + +```php +public function listBanners(Request $request, int $id): JsonResponse +{ + $tenantId = (int) $request->user()->tenant_id; + $campaign = AdCampaign::where('tenant_id', $tenantId)->where('id', $id)->firstOrFail(); + + return response()->json([ + 'approved_at' => $campaign->banners_approved_at, + 'max_bytes' => BannerUploadPolicy::MAX_BYTES, + 'formats' => ['jpg', 'png', 'gif'], + 'slots' => $this->bannerSlots($campaign), + ]); +} +``` + +- [ ] **Step 2:** хелперы `bannerSlots(AdCampaign $c): array` (прогон `BannerSizes::all()`, для каждого — искать + строку; собрать `{width,height,uploaded,banner_id,bytes,included,preview_url}`) и + `bannerSlot($c,$w,$h,?$banner)` для одиночного ответа. `preview_url` — только если загружен. +- [ ] **Step 3:** тесты PASS. + +### Task 6: toggle `included` + delete баннера + +**Files:** Modify: контроллер + `routes/web.php`; Test: `BannerToggleDeleteTest.php` + +- [ ] **Step 1: Тесты** — PATCH меняет `included`; DELETE убирает строку+файл и сбрасывает `banners_approved_at`; + чужой tenant → 404. +- [ ] **Step 2: Методы** `toggleBannerIncluded` (validate `included` boolean, update, вернуть slot), + `deleteBanner` (удалить файл+строку, сбросить approved, 204). Роуты `PATCH`/`DELETE + /campaigns/{id}/banners/{bannerId}`. +- [ ] **Step 3:** PASS. + +### Task 7: `approveBanners` учитывает `included` + +**Files:** Modify: контроллер; Test: `BannerApprovePartialTest.php` + +- [ ] **Step 1: Тесты** — approve при ≥1 загруженном+`included` → 200; если все `included=false` (или ни одного + загруженного) → 422 «Отметьте хотя бы один баннер для показа.». +- [ ] **Step 2:** в `approveBanners` заменить `exists()` на + `where('included', true)->exists()` + сообщение. +- [ ] **Step 3:** PASS. + +### Task 8: Админ CPM (чтение/запись `ad_settings.client_cpm_rub`) + +**Files:** Modify: `app/app/Http/Controllers/Api/AdminAdvertisingController.php` (+ методы `settings`/`updateSettings`), +`app/routes/web.php` (admin-группа); Test: `app/tests/Feature/Admin/AdminAdCpmTest.php` + +- [ ] **Step 1: Тесты** (admin-роль `crm_admin_user`, см. соседние Admin-тесты для авторизации): GET отдаёт текущий + `client_cpm_rub`; PUT `{client_cpm_rub:"90.00"}` пишет в `ad_settings`; PUT с `0`/пусто → 422. +- [ ] **Step 2: Методы** + +```php +public function settings(): JsonResponse +{ + return response()->json([ + 'client_cpm_rub' => (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00'), + ]); +} + +public function updateSettings(Request $request): JsonResponse +{ + $v = $request->validate(['client_cpm_rub' => ['required', 'numeric', 'gt:0']]); + DB::table('ad_settings')->update(['client_cpm_rub' => $v['client_cpm_rub']]); + return response()->json(['client_cpm_rub' => (string) $v['client_cpm_rub']]); +} +``` + + (Если `ad_settings` — single-row настройка без where, как `markup_percent` в этом же контроллере — повторить + тот же паттерн доступа.) +- [ ] **Step 3: Роуты** в admin-группе (рядом с прочими admin advertising). PASS. + +### Task 9: Прогон всего backend рекламы + +- [ ] `DB_DATABASE=liderra_testing php artisan migrate --force && php artisan test tests/Feature/Advertising tests/Feature/Admin/AdminAdCpmTest.php tests/Unit/Advertising` + Expected: всё зелёное (существующие banner-тесты, завязанные на старый `uploadBannerSource`/`bannerList`, + ОБНОВИТЬ под новый контракт — это часть Task 4/5). +- [ ] `composer pint` (только изменённые файлы) — чисто. + +--- + +## Frontend + +### Task 10: `advertising.ts` — новый контракт баннеров + admin cpm + +**Files:** Modify: `app/resources/js/api/advertising.ts`; Test: +`app/resources/js/tests/Frontend/advertising-api-banners.spec.ts` (если есть api-тесты; иначе покрыть через +компонентные тесты) + +- [ ] **Step 1:** Заменить тип `CampaignBanner` на `BannerSlot`: + +```ts +export interface BannerSlot { + width: number; + height: number; + uploaded: boolean; + banner_id: number | null; + bytes: number | null; + included: boolean; + preview_url: string | null; +} +export interface BannerSet { + approved_at: string | null; + max_bytes: number; + formats: string[]; + slots: BannerSlot[]; +} +``` + +- [ ] **Step 2:** API-функции: `fetchBanners(id): Promise`; + `uploadBanner(id, width, height, file): Promise<{slot:BannerSlot}>` (multipart с полями width/height/file) — + ЗАМЕНЯЕТ `uploadBannerSource`; `toggleBannerIncluded(id, bannerId, included)`; `deleteBanner(id, bannerId)`; + `approveBanners(id)` без изменений. Убрать `bannerPreviewUrl` (URL теперь приходит в slot). Admin: + `fetchAdCpm(): Promise<{client_cpm_rub:string}>`, `updateAdCpm(v)`. + +### Task 11: `CampaignWizard.vue` шаг 3 — сетка слотов + +**Files:** Modify: `app/resources/js/components/advertising/CampaignWizard.vue`; Test: +`app/tests/Frontend/advertising-campaign-wizard-banners-pergsize.spec.ts` + +- [ ] **Step 1: Тесты (Vitest, mount, замокать api-слой)**: + - рендерятся 15 слотов с подписью размера и «до 150 КБ». + - кнопка «Утвердить» скрыта, пока нет ни одного загруженного слота; появляется под сеткой после загрузки. + - у загруженного слота есть превью, галочка «в показ», «Заменить», «Удалить». + - переход на шаг 4 заблокирован, пока не утверждено. +- [ ] **Step 2: Реализация** — переписать блок `data-testid="wizard-step-3"`: + - Заголовок/подсказка простыми словами: «Загрузите готовые баннеры. Для каждого размера — свой файл нужного + размера. Мы ничего не режем.» + - `v-row` по `bannerSet.slots`: карточка со `{w}×{h}`, «до {{humanMax}}», `v-file-input` (accept image/*), + кнопка «Загрузить»/«Заменить», превью (`slot.preview_url`), `v-checkbox` «в показ» + (`toggleBannerIncluded`), «Удалить». Ошибка загрузки на конкретном слоте — под ним, понятным текстом + (сервер уже даёт «Нужен ровно 728×90…»). + - Кнопка «Утвердить баннеры» — `v-if="!bannersApproved && uploadedCount > 0"`, строго под сеткой. + - Заменить state: убрать `bannerFile/uploadBanner(single)`; добавить `uploadedCount` computed, per-slot upload + handler, `slotError` map. +- [ ] **Step 3:** обновить/заменить старые wizard-тесты, завязанные на одиночную загрузку. PASS. + +### Task 12: Цена из `audienceSize.cpm_rub` (не хардкод «120») + +**Files:** Modify: `CampaignWizard.vue` (шаг 2 стр.574, шаг 4 стр.721), +`app/resources/js/components/advertising/CampaignReportDialog.vue`, +`CampaignList.vue` (если есть «120»); Test: обновить существующие pokazy-spec. + +- [ ] **Step 1:** computed `cpmLabel = computed(() => \`${formatPlain(Number(audienceSize.value?.cpm_rub ?? 120))} за 1000 показов\`)`; + подставить вместо строк «120 ₽ за 1000 показов». В отчёте/списке — брать cpm из данных кампании/сметы, где + доступно; если нет — общий дефолт из настроек (не хардкод в тексте). +- [ ] **Step 2:** grep по `120 ₽` в `resources/js` — 0 совпадений после правки. +- [ ] **Step 3:** тесты PASS. + +### Task 13: убрать «Далее» на последнем шаге + +**Files:** Modify: `CampaignWizard.vue:788-797` + +- [ ] **Step 1: Тест** — на шаге 4 кнопки `wizard-next` нет в DOM. +- [ ] **Step 2:** `v-if="step !== stepTitles.length"` на кнопке «Далее». +- [ ] **Step 3:** PASS. + +### Task 14: Лимиты загрузки + понятные сообщения + client-side проверка + +**Files:** Modify: `CampaignWizard.vue` (проверка размера файла ДО отправки), демо-конфиг PHP. + +- [ ] **Step 1: Тест** — выбор файла > 150 КБ (баннер) / больше лимита (список) показывает понятное сообщение + «Файл слишком большой, до N» и не шлёт запрос. +- [ ] **Step 2:** перед `uploadBanner`/`uploadPhones` — проверка `file.size` против `max_bytes`/лимита; сообщение. + Для сервера: демо поднимать с `php -d upload_max_filesize=8M -d post_max_size=8M` (обновить команду демо в + хендоффе). На боевом — nginx/php-fpm (отложено, отметить в хендоффе, НЕ трогать прод). +- [ ] **Step 3:** PASS. + +### Task 15: Админ-контрол цены (Vue) + +**Files:** найти админ-экран рекламы (`resources/js/views/admin/…advertising…` — уточнить grep'ом); добавить поле +`client_cpm_rub` (чтение `fetchAdCpm`, сохранение `updateAdCpm`) с подписью «Цена клиенту за 1000 показов, ₽». +Test: Vitest на сохранение. + +- [ ] **Step 1: Тест** — поле показывает текущее значение, «Сохранить» зовёт `updateAdCpm`. +- [ ] **Step 2:** реализация в админ-компоненте рекламы (стиль — по соседним админ-настройкам). +- [ ] **Step 3:** PASS. + +### Task 16: Прогон фронта + типы + +- [ ] `npx vitest run tests/Frontend/advertising-*.spec.ts` — зелёно. +- [ ] `npm run type-check` — без ошибок по изменённым файлам. + +--- + +## Финал +- [ ] Полный прогон: backend (`tests/Feature/Advertising`, `Admin/AdminAdCpmTest`, `Unit/Advertising`) + фронт + (`advertising-*`). Числа зафиксировать. +- [ ] Финальный code-review субагентом всей ветки правок. +- [ ] Обновить хендофф-STATE (что сделано) — БЕЗ коммита. +- [ ] Коммит — ТОЛЬКО по «го» владельца (escape/стена, `LEFTHOOK_EXCLUDE=larastan`, явные пути). На бой/gitea — не пушить. + +## Самопроверка плана +- Покрытие фидбека: тексты (T11/T16 note), цена настраиваема (T12+T8+T15), «Утвердить» после галереи (T11), + замена+частичное (T4/T6/T7/T11), «Далее» убрать (T13), лимиты+сообщения (T14). ✓ +- Маржа не течёт: клиентские ответы баннеров/сметы не содержат `yandex_cost_rub` (не добавляем). ✓ +- RLS: единственная миграция — T1, идёт через rls-reviewer + CHANGELOG. ✓ diff --git a/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5d-rezhimy-cena.md b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5d-rezhimy-cena.md new file mode 100644 index 00000000..c8cf1db9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast5d-rezhimy-cena.md @@ -0,0 +1,121 @@ +# План Части 5d — два режима (авто/ручной) + клиентская цена + наценка + +> **Исполнителю:** СУБ-СКИЛ `superpowers:subagent-driven-development`. TDD, субагент НЕ коммитит. + +**Goal:** Дать клиенту выбор режима сбора аудитории (Авто/Ручной), сделать цену за 1000 показов +клиент-редактируемой (дефолт из админки), а наценку — настраиваемой в админке (дефолт 40%). Маржа/расход +в Директ клиенту НЕ видны. + +**Подтверждённый владельцем дизайн (26.07.2026):** +- **Авто:** скользящее окно `audience_days` — сделки за последние N дней, обновляется ежедневно, старые + отваливаются. ТОЛЬКО контакты системы (свой список НЕ подмешивается). Бюджет считаем сами. +- **Ручной:** фиксированный снимок — сделки за выбранный **период дат** (`snapshot_from…snapshot_to`) + свой + список номеров клиента. Клиент задаёт **срок показа** (`run_days`, длительность). Ничего не едет. +- **Цена:** клиент задаёт `client_cpm_rub` в мастере (дефолт из `ad_settings.client_cpm_rub`, сейчас 120). +- **Наценка:** `ad_settings.ad_margin_percent` (дефолт 40). В Директ уходит `× (1 − margin/100)` от цены и + бюджета — клиенту НЕ видно (Директ-отправка — Часть 4, здесь только храним `yandex_cost_rub`). + +**Стек:** Laravel 13 / PG16 RLS / Pest 4; Vue 3 + Vuetify 3 / Vitest. + +--- + +## Backend + +### T1: Миграции (поля кампании + настройка наценки) + rls-reviewer +**Files:** Create `database/migrations/2026_07_26_100500_add_mode_price_to_ad_campaigns.php`, +`database/migrations/2026_07_26_100600_add_margin_to_ad_settings.php`; Modify `db/CHANGELOG_schema.md`. + +- `ad_campaigns` +: + - `mode` VARCHAR(10) NOT NULL DEFAULT 'auto' (значения 'auto'|'manual'). + - `snapshot_from` DATE NULL, `snapshot_to` DATE NULL (ручной: период сделок). + - `run_days` SMALLINT NULL (ручной: срок показа, дней). + - `client_cpm_rub` DECIMAL(8,2) NULL (цена клиента; NULL → дефолт из ad_settings). + Аддитивно, RLS не трогаем; GRANT UPDATE у crm_app_user уже есть (v8.99). CHANGELOG-запись. +- `ad_settings` + `ad_margin_percent` DECIMAL(5,2) NOT NULL DEFAULT 40.00. GRANT: как у `client_cpm_rub` + (SELECT crm_app_user, SELECT+UPDATE crm_admin_user — свериться с миграцией `..._100000_add_client_cpm`). +- Прогнать `DB_DATABASE=liderra_testing php artisan migrate --force`. **rls-reviewer** на обе миграции. + +### T2: Модель + settings-провайдер +- `AdCampaign`: `mode`,`snapshot_from`,`snapshot_to`,`run_days`,`client_cpm_rub` в `$fillable`; + casts (`snapshot_from`/`to` → 'date:Y-m-d', `run_days`→int, `client_cpm_rub`→decimal:2). `yandex_cost_rub` + остаётся в `$hidden` (не отдаём клиенту). +- Хелпер получения цены: метод/сервис `effectiveCpm(AdCampaign): string` = `client_cpm_rub ?? ad_settings.client_cpm_rub ?? '120.00'`. +- Тест: casts + effectiveCpm (задан на кампании / дефолт). + +### T3: `CampaignAudienceBuilder` — учёт режима +**Files:** Modify `app/Services/Advertising/CampaignAudienceBuilder.php`; Test +`tests/Feature/Advertising/AudienceBuilderModeTest.php`. +- `auto`: сделки `received_at >= now()->subDays(audience_days)`, БЕЗ своего списка (даже если строки есть). +- `manual`: сделки `received_at` в `[snapshot_from 00:00, snapshot_to 23:59]` + свой список + `ad_campaign_phones` (как сейчас). `use_uploaded_list` в ручном = «клиент добавил свои». +- Тесты: (а) auto не включает список; (б) manual включает список; (в) manual берёт сделки по диапазону дат + (сделка внутри диапазона — да, вне — нет). + +### T4: `CampaignEstimateService` — цена кампании (не глобальная) +**Files:** Modify `app/Services/Advertising/CampaignEstimateService.php` (+ сигнатуру, чтобы принимать cpm +кампании); Modify `audienceSize` контроллера (передавать `effectiveCpm(campaign)`); Test обновить. +- `cpm` брать из `effectiveCpm(campaign)`, а не всегда `ad_settings`. `cost_rub = impressions × cpm/1000`. +- audience-size так же возвращает `cpm_rub` (теперь кампании). Клиенту `yandex_cost` НЕ отдаём. + +### T5: `store`/`update`/`submit` — новые поля + валидация режима +**Files:** Modify контроллер; Test `CampaignModeStoreTest.php`, дополнить `CampaignSubmitTest`. +- Валидация: `mode` in auto|manual; при `manual` — `snapshot_from`/`snapshot_to` (date, from ≤ to), + `run_days` (int ≥1); `client_cpm_rub` nullable numeric >0; при `auto` — `audience_days` как сейчас. +- `use_uploaded_list` разрешён только при `manual` (в auto форсим false на бэке). +- `submit`: при `manual` требовать заданные `snapshot_from/to` и `run_days` (иначе 422 понятным текстом). + Плюс уже действующие проверки (баннеры утверждены, есть included-баннер, частота/смета). + +### T6: `CampaignImpressionCharger` — цена кампании + запись маржи +**Files:** Modify `app/Services/Advertising/CampaignImpressionCharger.php`; Test дополнить +`CampaignImpressionChargerTest`. +- `cpm` брать из `effectiveCpm(campaign)` (не глобальный `ad_settings`). +- После расчёта клиентской суммы писать `yandex_cost_rub = charged × (1 − ad_margin_percent/100)` на кампанию + (для админ-маржи/Части 4). Клиенту это поле не видно (`$hidden`). +- Тесты: списание по цене кампании; `yandex_cost_rub` = 60% при марже 40%. + +### T7: Админ — наценка % +**Files:** Modify `AdminAdvertisingController::settings/updateSettings` (+ `ad_margin_percent`); Test дополнить +`AdminAdCpmTest`. +- GET отдаёт `{client_cpm_rub, ad_margin_percent}`. PUT принимает оба (margin: numeric 0..90). Валидация. + +--- + +## Frontend + +### T10: `advertising.ts` — новые поля + admin margin +- `CampaignCreate`/`Campaign` += `mode`,`snapshot_from`,`snapshot_to`,`run_days`,`client_cpm_rub`. +- admin: `fetchAdSettings()` → `{client_cpm_rub, ad_margin_percent}`; `updateAdSettings({...})` (заменяет + fetchAdCpm/updateAdCpm или расширяет). + +### T11: Мастер шаг 1 — выбор режима + поля +**Files:** `CampaignWizard.vue`. +- Наверху шага 1 — переключатель **режима** (`v-btn-toggle`/radio): «Авто (крутится сам)» / «Ручной (разовый)». + Простые подписи-пояснения. +- **Авто:** ползунок `audience_days` (как сейчас), БЕЗ блока своего списка. +- **Ручной:** два поля дат `snapshot_from`/`snapshot_to` («брать контакты за период»), блок своего списка + (файл + ссылка «Скачать пример» + textarea), поле `run_days` («сколько дней показывать»). +- Валидность перехода: auto → как сейчас; manual → даты заданы (from ≤ to) и run_days ≥1. + +### T12: Мастер шаг 2 — цена клиента + смета по ней +- Поле **«Цена за 1000 показов, ₽»** (`client_cpm_rub`, дефолт из audience-size/настройки, редактируемое). + При изменении — пересчёт сметы (audience-size c этой ценой, либо фронт умножает impressions×cpm/1000 — + Р37: лучше слать cpm в PATCH и брать cost с сервера). Простой русский, без слова «наценка». +- Смета показывает цену клиента и Итого; про наценку/Директ — НИ СЛОВА. + +### T13: Мастер шаг 4 — сводка под режим +- Показать режим (Авто/Ручной), для ручного — период дат и срок показа; цену клиента; Итого. + +### T14: Админ — поле «Наценка, %» +- В `AdminAdvertisingView.vue` рядом с ценой добавить поле `ad_margin_percent` (дефолт 40), сохранение + через `updateAdSettings`. Подпись «Наша наценка, % (в Директ уходит меньше)». + +### T15: Прогон +- Backend: `tests/Feature/Advertising` + `Admin/AdminAdCpmTest` + `Unit/Advertising` зелёные. +- Front: `vitest run tests/Frontend/advertising-*.spec.ts admin-advertising-view.spec.ts`; `vite build` OK; + `type-check` без новых ошибок по нашим файлам. +- Финальный code-review; ручная проверка в браузере (оба режима, смена цены, наценка в админке). + +## 🔴 Мины +- Маржа/`yandex_cost_rub`/`ad_margin_percent` — НИКОГДА в клиентском JSON. Проверить allowlist + тест. +- Часть 4 (Директ): фактическая отправка бюджета/ставки × (1−margin) — когда откроют доступ; здесь только храним. +- `use_uploaded_list`/свой список — только в manual (в auto форсить false на бэке, не только прятать на фронте). diff --git a/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast6-spisanie-otchet.md b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast6-spisanie-otchet.md new file mode 100644 index 00000000..2a7c301f --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-yandex-reklama-pokazy-chast6-spisanie-otchet.md @@ -0,0 +1,172 @@ +# План — Реклама «за показы», Часть 6 (сейчас): списание по факту + клиентский отчёт + +> **Для агентов:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`. Шаги — чекбоксами. + +**Goal:** Построить Direct-НЕзависимую часть «сопровождения»: (6a) денежное ядро списания по факту +показов — сервис `CampaignImpressionCharger` (списание = показано × 120 ₽/1000, стоп по оплаченному, +идемпотентно) + статус `completed`; (6b) клиентский отчёт по показам — переделка `CampaignReportDialog` +(сейчас читает старый `weekly_budget_rub`) + статусы `queued`/`completed`. + +**Что ОТЛОЖЕНО до Части 4 (нужен Директ):** джоб, тянущий фактические показы из отчёта Директа и +зовущий `CampaignImpressionCharger`; `campaigns.suspend` в Директе при стопе; переделка админ-маржи +(`AdminAdvertisingController`) с наценки-% на реальный `yandex_cost_rub` из отчёта Директа. Пока Директа +нет — реального расхода Яндекса нет, трогать рабочую админку не будем (даст неверную маржу). + +**Tech Stack:** Laravel 13 / Pest 4 / bcmath (backend); Vue 3 + Vuetify 3 + Vitest (frontend). +**Ветка:** `feat/reklama-yandex-pokazy` (HEAD после Ч.5b `de9f0d7d`). Тестовая БД `liderra_testing` ОБЩАЯ: +перед бэкенд-тестами `DB_DATABASE=liderra_testing php artisan migrate --force`. Фронт: `npx vitest run`. +Коммит — контроллер по «го». Субагент НЕ коммитит (§15.1). + +--- + +## Что уже есть (не переделывать) + +- `AdImpressionPricing`: `clientCostRub(int $impressions, string $cpmRub): string` (сумма 120₽/1000 вверх + до копейки), `marginRub(...)`. `ad_settings.client_cpm_rub` = 120. +- `AdWalletService::charge(int $tenantId, string $channel, string $relatedType, int $relatedId, + string $amountRub, string $externalKey): void` — идемпотентно по `externalKey`, проверяет средства. +- Модель `AdCampaign`: поля `delivered_impressions`(default 0), `paid_impressions`, `charged_client_rub` + (default 0), `estimated_impressions`, `budget_rub`, `frequency`; статусы + `STATUS_QUEUED` (Ч.5b). +- `AdvertisingCampaignController::show()` уже отдаёт `campaign` (модель, без `yandex_cost_rub` — $hidden) + + `ads` + `spent_rub` (сумма charge-транзакций). + +--- + +## Файловая карта + +- Изменить: `app/app/Models/AdCampaign.php` — `STATUS_COMPLETED`. +- Создать: `app/app/Services/Advertising/CampaignImpressionCharger.php`. +- Изменить: `app/resources/js/components/advertising/CampaignReportDialog.vue` — показы вместо недельного + бюджета + статусы queued/completed. +- Изменить: `app/resources/js/components/advertising/CampaignList.vue` — метка `completed` (у `queued` уже есть). +- Тесты: `app/tests/Feature/Advertising/CampaignImpressionChargerTest.php`, + `app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts` (или дополнить существующий report-спек). + +--- + +## Task 1: статус completed (backend, TDD-совместно с Task 2) + +**Files:** Изменить `app/app/Models/AdCampaign.php`. + +- [ ] Добавить константу рядом с прочими STATUS_*: `public const STATUS_COMPLETED = 'completed';` + (смысл — «показы полностью откручены/оплачены»). Отдельного теста не нужно — покрывается Task 2. + +--- + +## Task 2: CampaignImpressionCharger — списание по факту показов (backend, TDD) + +**Files:** Создать `app/app/Services/Advertising/CampaignImpressionCharger.php`; +Тест `app/tests/Feature/Advertising/CampaignImpressionChargerTest.php`. + +**Контракт сервиса:** +```php +final class CampaignImpressionCharger +{ + public function __construct( + private readonly AdImpressionPricing $pricing, + private readonly AdWalletService $wallet, + ) {} + + /** + * Списать с рекламного кошелька клиента за ФАКТИЧЕСКИ показанные показы. + * $deliveredImpressions — накопительное число показов из отчёта Директа + * (Часть 4 подаёт его; сервис Direct-НЕзависим — принимает числом). + * + * Логика: реальные показы режем по оплаченному (`paid_impressions`); считаем + * суммарную клиентскую стоимость по 120₽/1000 (AdImpressionPricing); списываем + * ДЕЛЬТУ (суммарно − уже списано `charged_client_rub`); обновляем + * delivered_impressions + charged_client_rub; при показано ≥ оплачено — статус + * completed. Идемпотентно: повторный вызов с тем же delivered ничего не спишет. + */ + public function charge(AdCampaign $campaign, int $deliveredImpressions): void +} +``` + +**Реализация (money — bcmath, scale 2):** +1. `$cpm = (string) (DB::table('ad_settings')->value('client_cpm_rub') ?? '120.00');` +2. `$paid = (int) ($campaign->paid_impressions ?? 0);` — если `paid` > 0, `$billable = min($delivered, $paid)`, + иначе `$billable = max(0, $delivered)` (нет лимита — считаем по факту). +3. `$totalCost = $this->pricing->clientCostRub($billable, $cpm);` — суммарная клиентская стоимость. +4. `$already = (string) ($campaign->charged_client_rub ?? '0.00');` + `$delta = bcsub($totalCost, $already, 2);` +5. Если `bccomp($delta, '0.00', 2) === 1` (дельта > 0): списать + `$this->wallet->charge((int)$campaign->tenant_id, 'yandex', 'campaign', (int)$campaign->id, $delta, + "yandex-imp:{$campaign->id}:{$billable}");` (external_key по накопленным оплачиваемым показам — + идемпотентность по «докуда уже списали»). +6. Обновить кампанию: `delivered_impressions = $delivered`; `charged_client_rub = $totalCost`; + если `$paid > 0 && $delivered >= $paid` → `status = AdCampaign::STATUS_COMPLETED`. Один `->update([...])`. + +**Тесты (`CampaignImpressionChargerTest.php`, `uses(DatabaseTransactions::class)`):** +- [ ] **Списание по факту:** paid=10000, charged=0, delivered=2500 → charge вызван на `clientCostRub(2500,120)` + = «300.00» (2500×120/1000); `delivered_impressions=2500`, `charged_client_rub='300.00'`, статус НЕ completed. +- [ ] **Идемпотентность:** второй вызов с delivered=2500 → дельта 0 → charge НЕ вызывается повторно + (проверить кол-во charge-транзакций = 1 в `ad_wallet_transactions`, либо мок AdWalletService и assert once). +- [ ] **Докрутка:** после 2500 приходит delivered=6000 → списывается только ДЕЛЬТА + (`clientCostRub(6000)−clientCostRub(2500)`), `charged_client_rub` = clientCostRub(6000). +- [ ] **Стоп по оплаченному:** paid=5000, delivered=5000 → `charged_client_rub=clientCostRub(5000)`, + статус `completed`. +- [ ] **Перекрут режем:** paid=5000, delivered=8000 → billable=5000, списание как за 5000 (не за 8000), + статус `completed`. +- [ ] **Ноль:** delivered=0 → ничего не списано, charged=0, статус не меняется. + +Используй реальный `AdWalletService` (транзакции в тесте) + пополни кошелёк тенанта заранее +(`AdWalletService::topup` или фабрика `ad_wallets` с балансом), чтобы средств хватало на списание. +Точную фабрику кошелька/тенанта подсмотри в существующих тестах рекламы (`AdvertisingWallet*Test`, +`ChargeCampaignSpend*`-тесты если есть). Проверить внешний ключ идемпотентности реально работает +(`ad_wallet_transactions.external_key` unique — повтор не задваивает). + +- [ ] Прогон: `DB_DATABASE=liderra_testing php artisan migrate --force && DB_DATABASE=liderra_testing php artisan test --filter=CampaignImpressionCharger` → зелёно. Затем весь `tests/Feature/Advertising` — не сломать. + +--- + +## Task 3: CampaignReportDialog под показы (frontend, TDD) + +> Навык дизайна: строго Vuetify 3 + Forest, повторять паттерны текущего файла (`.report-stat`, `.num`). + +**Files:** Изменить `app/resources/js/components/advertising/CampaignReportDialog.vue`; +Тест `app/tests/Frontend/advertising-campaign-report-pokazy.spec.ts` (новый или дополнить report-спек). + +Изменения: +- `STATUS_META` — добавить `queued: { label:'Готова к запуску', color:'info', icon:'mdi-clock-check-outline' }` + и `completed: { label:'Показы откручены', color:'success', icon:'mdi-check-all' }` (иначе рантайм-ошибка + доступа по несуществующему ключу для этих статусов). +- Заменить строку «Бюджет в неделю» (`weekly_budget_rub`) на блок показов: + - «Показов оплачено» = `campaign.estimated_impressions` (или `paid_impressions` если есть в типе; сейчас + в типе `Campaign` есть `estimated_impressions` — используем его как «оплачено/смета»); + - «Показано» = `campaign.delivered_impressions` (добавить поле в тип `Campaign` в `advertising.ts`, + опциональное `delivered_impressions?: number`); + - «Частота» = `campaign.frequency`. +- «Потрачено» (`spent_rub`) — оставить (это списанное по факту, деньги клиента, показывать можно). +- 🔴 НЕ показывать `yandex_cost_rub`/маржу (их и нет в клиентском JSON — $hidden). +- Список `ads` для показов пуст — оставить как есть (текст «статистика появится после подключения + Директа» уместен), либо заменить на «Баннеров: N» если легко (не обязательно). + +Тест (`advertising-campaign-report-pokazy.spec.ts`, мок `fetchCampaign`): при открытии диалога с кампанией +показов (status `completed`, estimated_impressions=5000, delivered_impressions=5000, frequency=5, +spent_rub='600.00') — в разметке видно «Показано» 5000, «Частота» 5, «Потрачено» 600, метку «Показы +откручены»; НЕТ «Бюджет в неделю»/«weekly»/«yandex_cost»/«клик». + +- [ ] Прогон: `npx vitest run tests/Frontend/advertising-campaign-report-pokazy.spec.ts` + существующий + `advertising-campaign-report*.spec.ts` (если есть — обновить под показы, не оставлять красным). + +--- + +## Task 4: CampaignList.vue — метка completed (frontend, мелко) + +**Files:** Изменить `app/resources/js/components/advertising/CampaignList.vue`. +- [ ] В `STATUS_META` добавить `completed: { label:'Показы откручены', color:'success', icon:'mdi-check-all' }`. + Обновить/добавить тест списка при необходимости. + +--- + +## Готовность (self-review контроллера) + +- [ ] Деньги: списание = показано×120/1000, дельта, идемпотентно (external_key), стоп/`completed` при + оплаченном, перекрут режется, ноль-кейс. Все boundary-тесты зелёные. bcmath, без float. +- [ ] Клиент нигде не видит `yandex_cost_rub`/маржу; отчёт по показам, не по клик-бюджету. +- [ ] Статусы `queued`/`completed` есть во всех местах, где рендерится STATUS_META (список, отчёт) — нет + рантайм-обращений к несуществующему ключу. +- [ ] Миграций нет (статусы — `string(24)` без CHECK) → CHANGELOG_schema не трогаем; если реализатор + добавит миграцию — запись + `rls-reviewer`. +- [ ] Отложенное (джоб из Директа, суспенд, админ-маржа по реальному расходу) — явно в HANDOFF, ждёт Ч.4. +- [ ] Ничего на бой/gitea без «го». diff --git a/docs/superpowers/plans/2026-07-27-yandex-creative-robot.md b/docs/superpowers/plans/2026-07-27-yandex-creative-robot.md new file mode 100644 index 00000000..518d1c8f --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-yandex-creative-robot.md @@ -0,0 +1,2953 @@ +# Робот-грузчик креативов + набор креативов в ядре — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Автоматизировать единственный ручной шаг медийной кампании — загрузку картиночных креативов в веб-кабинет Яндекс Директа — и перевести ядро с «одного креатива на кампанию» на «креатив у каждого баннера». + +**Architecture:** Портал через официальный API делает всё, кроме загрузки картинок — её `creatives.add` не умеет. Эту загрузку делает отдельный робот на Playwright, живущий на боевом сервере: он сам опрашивает портал, скачивает файлы, кладёт их в кабинет и говорит «готово». Номера созданных креативов портал добывает сам — слепком списка `creatives.get` до и после загрузки, сопоставляя по `Width`/`Height`. Робот не читает номера, не создаёт кампаний и не запускает показы. + +**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 / Pest 4 — портал. Node 20 / Playwright / nodemailer — робот. + +**Спека:** [2026-07-27-yandex-creative-robot-design.md](../specs/2026-07-27-yandex-creative-robot-design.md) +**Находка-основание:** [2026-07-27-yandex-konstruktor-kreativov-zakryt.md](../findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md) + +--- + +## Что уже есть в коде (не переписывать) + +Проверено чтением файлов 27.07.2026 — эти куски работают и переиспользуются: + +| Уже готово | Где | +|---|---| +| Клиент грузит файл баннера, проверка точного размера из `BannerSizes`, формат, вес | `AdvertisingCampaignController::uploadBanner` | +| **Один файл на размер** — гарантирован ключом `updateOrCreate(tenant_id, campaign_id, width, height)` | там же | +| Список/удаление/включение баннеров, превью | `listBanners`, `deleteBanner`, `toggleBannerIncluded`, `previewBanner` | +| Медийные методы Директа | `YandexDirectClient::addCpmBannerCampaign/AdGroup/MediaAudienceTarget/CpmBannerAd/getCreativePreview` | +| Заморозка/разморозка денег на 4 выходах кампании | `AdWalletService`, `CampaignImpressionCharger`, `PauseCampaignsOnAdStop` | +| Образец робота: вход, keep-alive, сторож, письма | `bots/mts-telegram-ads/` | +| Образец сервис-токена | `App\Http\Middleware\SalesIntegrationToken` + `bootstrap/app.php` алиас `sales-integration` | + +## Файловая структура + +**Фаза 1 — ядро (портал):** + +- Создать `app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php` — номера креатива/объявления и статус модерации у баннера. +- Создать `app/app/Services/Advertising/CreativeIdMatcher.php` — чистое сопоставление слепков «до/после» с баннерами. Без БД и сети. +- Изменить `app/app/Models/AdCampaignBanner.php` — новые поля и константы модерации. +- Изменить `app/app/Services/Advertising/BannerUploadPolicy.php` — кап веса 150 КБ → 512 КБ. +- Изменить `app/app/Services/Advertising/YandexDirectClient.php` — `listImageCreativeIds()`. +- Изменить `app/app/Services/Advertising/CampaignLauncher.php` — объявление на каждый баннер. +- Изменить `app/app/Jobs/SyncCampaignModerationJob.php` — модерация по баннерам, правило «хотя бы одно принято». + +**Фаза 2 — очередь и канал робота (портал):** + +- Создать `app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php` — очередь заданий робота. +- Создать `app/app/Models/AdCreativeJob.php`. +- Создать `app/app/Services/Advertising/CreativeJobService.php` — постановка задания, приём результата, сопоставление номеров. +- Создать `app/app/Http/Middleware/CreativeRobotToken.php` — сервис-токен робота. +- Создать `app/app/Http/Controllers/Api/CreativeRobotController.php` — три адреса для робота. +- Изменить `app/bootstrap/app.php`, `app/routes/web.php`, `app/config/services.php`. + +**Фаза 3 — робот:** + +- Создать `bots/yandex-creatives/` — `package.json`, `src/config.js`, `src/portal.js`, `src/browser.js`, `src/session.js`, `src/cabinet.js`, `src/runner.js`, `src/mailer.js`, `bin/login.js`, `bin/run.js`, `bin/keepalive.js`, `test/*.test.js`, `docs/cabinet-flow.md`, `README.md`. + +**Фаза 1+2 самодостаточны:** после них портал умеет вести набор креативов и запускать кампанию с N объявлениями, а номера пока вписывает оператор руками. Робот (фаза 3) заменяет ручной ввод. + +--- + +## Как гонять тесты + +```bash +cd app +php artisan test --filter=Advertising +``` + +База — `liderra_testing`. Если отстала: `DB_DATABASE=liderra_testing php artisan migrate --force`. +**Larastan в этом worktree не запускается** (junction-vendor) — опираться на Pest, `composer stan` гонять на обычном чекауте. + +Коммиты: `LEFTHOOK_EXCLUDE=larastan git commit -F <файл>`, сообщение без круглых скобок, **только по «go» владельца**. + +--- + +# ФАЗА 1 — ядро под набор креативов + +## Task 1: Колонки номеров и модерации у баннера + +**Files:** + +- Create: `app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php` +- Modify: `db/CHANGELOG_schema.md` +- Test: `app/tests/Feature/Advertising/BannerYandexIdsTest.php` + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Feature/Advertising/BannerYandexIdsTest.php`: + +```php +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 300, + 'height' => 250, + 'path' => 'ad-banners/x/300x250.jpg', + 'bytes' => 1000, + 'yandex_creative_id' => 1163586673, + 'yandex_ad_id' => 17786692536, + 'moderation_status' => AdCampaignBanner::MOD_ACCEPTED, + 'moderation_reason' => null, + ]); + + $fresh = $banner->fresh(); + expect($fresh->yandex_creative_id)->toBe(1163586673) + ->and($fresh->yandex_ad_id)->toBe(17786692536) + ->and($fresh->moderation_status)->toBe('ACCEPTED'); +}); + +it('defaults moderation status to draft', function () { + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => 728, + 'height' => 90, + 'path' => 'ad-banners/x/728x90.jpg', + 'bytes' => 1000, + ]); + + expect($banner->fresh()->moderation_status)->toBe('draft') + ->and($banner->fresh()->yandex_creative_id)->toBeNull(); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd app && php artisan test --filter=BannerYandexIds` +Expected: FAIL — `column "yandex_creative_id" of relation "ad_campaign_banners" does not exist`. + +- [ ] **Step 3: Написать миграцию** + +Создать `app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php`: + +```php +unsignedBigInteger('yandex_creative_id')->nullable()->after('bytes'); + $table->unsignedBigInteger('yandex_ad_id')->nullable()->after('yandex_creative_id'); + $table->string('moderation_status', 16)->default('draft')->after('yandex_ad_id'); + $table->string('moderation_reason')->nullable()->after('moderation_status'); + }); + + // 🔴 На проде crm_supplier_worker и crm_admin_user НЕ BYPASSRLS. Табличные GRANT у + // ad_campaign_banners уже есть у crm_app_user; джобам модерации нужен UPDATE через + // pgsql_supplier. Кросс-тенантный доступ служебных ролей даёт db/03_service_bypass_policies.sql — + // после выката ПЕРЕзапустить его, иначе джоб молча увидит ноль строк. + DB::statement('GRANT SELECT, UPDATE ON ad_campaign_banners TO crm_supplier_worker'); + } + + public function down(): void + { + DB::statement('REVOKE SELECT, UPDATE ON ad_campaign_banners FROM crm_supplier_worker'); + + Schema::table('ad_campaign_banners', function (Blueprint $table) { + $table->dropColumn(['yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason']); + }); + } +}; +``` + +- [ ] **Step 4: Обновить модель** + +В `app/app/Models/AdCampaignBanner.php` заменить блок `$fillable` и `casts()` и добавить константы: + +```php +class AdCampaignBanner extends Model +{ + public const MOD_DRAFT = 'draft'; + + public const MOD_MODERATION = 'MODERATION'; + + public const MOD_ACCEPTED = 'ACCEPTED'; + + public const MOD_REJECTED = 'REJECTED'; + + protected $fillable = [ + 'tenant_id', 'campaign_id', 'width', 'height', 'path', 'bytes', 'included', + 'yandex_creative_id', 'yandex_ad_id', 'moderation_status', 'moderation_reason', + ]; + + protected function casts(): array + { + return [ + 'tenant_id' => 'integer', + 'campaign_id' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'bytes' => 'integer', + 'included' => 'boolean', + 'yandex_creative_id' => 'integer', + 'yandex_ad_id' => 'integer', + ]; + } +``` + +- [ ] **Step 5: Применить миграцию и прогнать тест** + +Run: + +```bash +cd app && DB_DATABASE=liderra_testing php artisan migrate --force && php artisan test --filter=BannerYandexIds +``` + +Expected: PASS, 2 теста. + +- [ ] **Step 6: Записать в CHANGELOG схемы** + +В `db/CHANGELOG_schema.md` добавить запись новой версии (следующая после v9.02) с текстом: + +```markdown +## v9.03 — 27.07.2026 — номера креативов переезжают с кампании на баннер + +`ad_campaign_banners` + `yandex_creative_id`, `yandex_ad_id`, `moderation_status` (default `draft`), +`moderation_reason`. GRANT SELECT, UPDATE для `crm_supplier_worker` (джоб модерации). + +Причина: конструктор креативов Яндекса закрыт 01.06.2026, адаптивного креатива не существует — +медийная кампания состоит из объявления на каждый размер блока со своим креативом +(findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md). Поле `ad_campaigns.yandex_creative_id` +остаётся как аварийный ручной путь и будет снято отдельно. + +🔴 После выката на прод ПЕРЕзапустить `db/03_service_bypass_policies.sql`. +``` + +- [ ] **Step 7: Прогнать rls-reviewer** + +Вызвать агента `rls-reviewer` на изменения `app/database/migrations/2026_07_27_100000_*`. Ожидание: замечаний нет либо они исправлены до перехода к Task 2. + +- [ ] **Step 8: Коммит по «go» владельца** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/database/migrations/2026_07_27_100000_add_yandex_ids_to_ad_campaign_banners.php app/app/Models/AdCampaignBanner.php app/tests/Feature/Advertising/BannerYandexIdsTest.php db/CHANGELOG_schema.md +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: номера креатива и объявления переезжают на баннер + +Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все +размеры не существует, медийная кампания состоит из объявления на каждый размер. +Строка баннера становится единицей «размер плюс файл плюс креатив плюс объявление». + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 2: Кап веса баннера 150 КБ → 512 КБ + +**Files:** + +- Modify: `app/app/Services/Advertising/BannerUploadPolicy.php` +- Test: `app/tests/Unit/Advertising/BannerUploadPolicyTest.php` + +Яндекс принимает графические креативы весом до 512 КБ (подтверждено HANDOFF прогрева §5.1 — +15 загруженных баннеров ≤ 512 КБ приняты). Наш кап стоял 150 КБ с пометкой «сверка с Яндексом — +Часть 4». Сверка проведена — поднимаем. + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Unit/Advertising/BannerUploadPolicyTest.php`: + +```php +toBe(512000); +}); + +it('formats the limit for humans in kilobytes', function () { + expect(BannerUploadPolicy::humanMaxSize())->toBe('500 КБ'); +}); + +it('accepts the image formats yandex takes', function () { + expect(BannerUploadPolicy::FORMATS)->toBe(['jpg', 'jpeg', 'png', 'gif']); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd app && php artisan test --filter=BannerUploadPolicy` +Expected: FAIL — `Failed asserting that 153600 is identical to 512000`. + +- [ ] **Step 3: Поднять кап** + +В `app/app/Services/Advertising/BannerUploadPolicy.php` заменить строку константы: + +```php + // 512 000 байт — предел Яндекса для графического креатива медийной кампании. Сверено + // с живой кампанией прогрева: 15 баннеров ≤ 512 КБ приняты кабинетом (HANDOFF 25.07 §5.1). + // Прежнее значение 150 КБ было нашим временным капом до сверки. + public const MAX_BYTES = 512000; +``` + +- [ ] **Step 4: Прогнать тест — должен пройти** + +Run: `cd app && php artisan test --filter=BannerUploadPolicy` +Expected: PASS, 3 теста. + +- [ ] **Step 5: Проверить, что клиентская загрузка не сломалась** + +Run: `cd app && php artisan test --filter=Advertising` +Expected: PASS — прежние тесты загрузки баннеров зелёные. + +- [ ] **Step 6: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Services/Advertising/BannerUploadPolicy.php app/tests/Unit/Advertising/BannerUploadPolicyTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: кап веса баннера поднят до предела Яндекса 512 КБ + +Наш временный кап 150 КБ стоял до сверки с Яндексом. Сверка проведена по живой +кампании прогрева — приняты баннеры до 512 КБ. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 3: Слепок картиночных креативов аккаунта + +**Files:** + +- Modify: `app/app/Services/Advertising/YandexDirectClient.php` +- Test: `app/tests/Feature/Advertising/YandexDirectMediaClientTest.php` (файл существует — дописать) + +- [ ] **Step 1: Написать падающий тест** + +Дописать в `app/tests/Feature/Advertising/YandexDirectMediaClientTest.php`: + +```php +it('lists image creative ids with their sizes and ignores video creatives', function () { + Http::fake([ + '*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 1163441165, 'Type' => 'VIDEO_EXTENSION_CREATIVE', 'Width' => 1080, 'Height' => 1080], + ['Id' => 1163586673, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ['Id' => 1163586680, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + ]); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + // Картиночные креативы Яндекс хранит типом HTML5_CREATIVE даже для загруженных JPG — + // проверено живым запросом 27.07.2026. Видео-креативы в набор попадать не должны. + expect($client->listImageCreativeIds())->toBe([ + 1163586673 => [300, 250], + 1163586680 => [728, 90], + ]); +}); + +it('returns an empty snapshot when the account has no creatives', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => []])]); + + $client = new YandexDirectClient('https://api.direct.yandex.com', 'T'); + + expect($client->listImageCreativeIds())->toBe([]); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd app && php artisan test --filter=YandexDirectMediaClient` +Expected: FAIL — `Call to undefined method ...::listImageCreativeIds()`. + +- [ ] **Step 3: Добавить метод** + +В `app/app/Services/Advertising/YandexDirectClient.php` после `getCreativePreview()` добавить: + +```php + /** + * Creatives.get: слепок картиночных креативов аккаунта — номер → [ширина, высота]. + * + * Нужен для опознания креативов, загруженных роботом в веб-кабинет: имя у всех + * загруженных одинаковое («Новый креатив» — его ставит Яндекс), поэтому опознаём + * разницей слепков «до/после» и сопоставлением по размеру. Загруженные JPG/PNG/GIF + * Яндекс хранит типом HTML5_CREATIVE — проверено живым запросом 27.07.2026. + * + * @return array + */ + public function listImageCreativeIds(): array + { + $res = $this->call('v5/creatives', 'get', [ + 'SelectionCriteria' => [], + 'FieldNames' => ['Id', 'Type', 'Width', 'Height'], + ]); + + $out = []; + foreach ($res['result']['Creatives'] ?? [] as $creative) { + if (($creative['Type'] ?? null) !== 'HTML5_CREATIVE') { + continue; + } + $out[(int) $creative['Id']] = [(int) $creative['Width'], (int) $creative['Height']]; + } + + return $out; + } +``` + +- [ ] **Step 4: Прогнать тест — должен пройти** + +Run: `cd app && php artisan test --filter=YandexDirectMediaClient` +Expected: PASS. + +- [ ] **Step 5: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Services/Advertising/YandexDirectClient.php app/tests/Feature/Advertising/YandexDirectMediaClientTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: слепок картиночных креативов аккаунта через creatives.get + +Тип HTML5_CREATIVE и поля Width Height проверены живым read-only запросом к боевому +кабинету 27.07.2026. Видео-креативы в слепок не попадают. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 4: Сопоставление слепков «до/после» с баннерами + +**Files:** + +- Create: `app/app/Services/Advertising/CreativeIdMatcher.php` +- Test: `app/tests/Unit/Advertising/CreativeIdMatcherTest.php` + +Чистая функция без БД и сети: на входе слепок «до», слепок «после» и ожидаемые размеры; +на выходе «размер → номер креатива» либо понятная ошибка. Наугад не привязываем ничего. + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Unit/Advertising/CreativeIdMatcherTest.php`: + +```php + [300, 250]]; + $after = [111 => [300, 250], 222 => [300, 250], 333 => [728, 90]]; + + $matched = (new CreativeIdMatcher)->match($before, $after, [[300, 250], [728, 90]]); + + expect($matched)->toBe(['300x250' => 222, '728x90' => 333]); +}); + +it('fails when a expected size did not appear', function () { + $before = []; + $after = [222 => [300, 250]]; + + expect(fn () => (new CreativeIdMatcher)->match($before, $after, [[300, 250], [728, 90]])) + ->toThrow(CreativeMatchFailedException::class, '728x90'); +}); + +it('fails when two new creatives share one size', function () { + $before = []; + $after = [222 => [300, 250], 223 => [300, 250]]; + + expect(fn () => (new CreativeIdMatcher)->match($before, $after, [[300, 250]])) + ->toThrow(CreativeMatchFailedException::class, '300x250'); +}); + +it('ignores new creatives of sizes we did not ask for', function () { + $before = []; + $after = [222 => [300, 250], 999 => [160, 600]]; + + $matched = (new CreativeIdMatcher)->match($before, $after, [[300, 250]]); + + expect($matched)->toBe(['300x250' => 222]); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd app && php artisan test --filter=CreativeIdMatcher` +Expected: FAIL — класса нет. + +- [ ] **Step 3: Написать исключение** + +Создать `app/app/Exceptions/Advertising/CreativeMatchFailedException.php`: + +```php + $before слепок до загрузки: номер → [ш, в] + * @param array $after слепок после загрузки + * @param list $expectedSizes размеры, которые робот должен был залить + * @return array «300x250» → номер креатива + * + * @throws CreativeMatchFailedException + */ + public function match(array $before, array $after, array $expectedSizes): array + { + $newIds = array_diff_key($after, $before); + + $bySize = []; + foreach ($newIds as $id => [$w, $h]) { + $bySize[$w.'x'.$h][] = (int) $id; + } + + $out = []; + foreach ($expectedSizes as [$w, $h]) { + $key = $w.'x'.$h; + $found = $bySize[$key] ?? []; + + if (count($found) === 0) { + throw new CreativeMatchFailedException( + "После загрузки не появился креатив размера {$key}." + ); + } + if (count($found) > 1) { + throw new CreativeMatchFailedException( + "После загрузки появилось несколько креативов размера {$key} — какой чей, не определить." + ); + } + + $out[$key] = $found[0]; + } + + return $out; + } +} +``` + +- [ ] **Step 5: Прогнать тест — должен пройти** + +Run: `cd app && php artisan test --filter=CreativeIdMatcher` +Expected: PASS, 4 теста. + +- [ ] **Step 6: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Services/Advertising/CreativeIdMatcher.php app/app/Exceptions/Advertising/CreativeMatchFailedException.php app/tests/Unit/Advertising/CreativeIdMatcherTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: опознание креативов слепком до и после загрузки + +Имена не годятся — Яндекс ставит всем загруженным одинаковое «Новый креатив». +Опознаём разницей слепков и размером. При любой неоднозначности бросаем ошибку, +наугад не привязываем. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 5: Запуск создаёт объявление на каждый баннер + +**Files:** + +- Modify: `app/app/Services/Advertising/CampaignLauncher.php` +- Test: `app/tests/Feature/Advertising/CampaignLauncherTest.php` + +- [ ] **Step 1: Написать падающие тесты** + +В `app/tests/Feature/Advertising/CampaignLauncherTest.php` добавить хелпер и тесты. +Хелпер — рядом с `seedAudience()`: + +```php +/** Кладёт кампании набор баннеров с номерами креативов, как будто робот уже отработал. */ +function seedBanners(AdCampaign $campaign, array $sizesToCreativeId): void +{ + foreach ($sizesToCreativeId as $size => $creativeId) { + [$w, $h] = array_map('intval', explode('x', (string) $size)); + AdCampaignBanner::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => $h, + 'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$size}.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => $creativeId, + ]); + } +} +``` + +Добавить `use App\Models\AdCampaignBanner;` в шапку файла и тесты: + +```php +it('creates one ad per included banner and stores the ad id on each banner', function () { + configureYandex(); + Http::fake([ + '*/segments/upload_csv_file' => Http::response(['segment' => ['id' => 900001]]), + '*/segment/*/confirm' => Http::response(['segment' => ['id' => 900001]]), + '*/json/v5/retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), + '*/json/v5/campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + '*/json/v5/adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), + '*/json/v5/audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), + // Каждый вызов ads.add отдаёт свой номер объявления. + '*/json/v5/ads' => Http::sequence() + ->push(['result' => ['AddResults' => [['Id' => 5551]]]]) + ->push(['result' => ['AddResults' => [['Id' => 5552]]]]), + ]); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + + app(CampaignLauncher::class)->launch($campaign); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners)->toHaveCount(2) + ->and($banners[0]->yandex_ad_id)->toBe(5551) + ->and($banners[0]->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION) + ->and($banners[1]->yandex_ad_id)->toBe(5552); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); + + Http::assertSentCount(7); // segment upload + confirm + retargeting + campaign + adgroup + audiencetarget + 2 ads = 8? см. шаг 2 +}); + +it('skips banners that are switched off by the client', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->update(['included' => false]); + + app(CampaignLauncher::class)->launch($campaign); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/ads') + && ($request['params']['Ads'][0]['CpmBannerAdBuilderAd']['Creative']['CreativeId'] ?? null) === 4242; + }); + + $off = AdCampaignBanner::where('campaign_id', $campaign->id)->where('width', 728)->first(); + expect($off->yandex_ad_id)->toBeNull(); +}); + +it('refuses to launch when a included banner has no creative number yet', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + seedBanners($campaign, ['300x250' => 4242]); + AdCampaignBanner::where('campaign_id', $campaign->id)->update(['yandex_creative_id' => null]); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'креатив'); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); + Http::assertNothingSent(); +}); + +it('refuses to launch when the campaign has no banners at all', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['yandex_creative_id' => null]); + seedAudience($campaign, 100); + + expect(fn () => app(CampaignLauncher::class)->launch($campaign)) + ->toThrow(RuntimeException::class, 'баннер'); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); + Http::assertNothingSent(); +}); +``` + +Существующий тест `launches an impression campaign: …` проверяет `yandex_ad_id` на кампании и +единственное объявление — переписать его под набор: заменить `'yandex_creative_id' => 4242` в +`makeImpressionCampaign` на `null` и добавить `seedBanners($campaign, ['300x250' => 4242]);`, +а проверку `->and($campaign->yandex_ad_id)->toBe(555)` убрать. + +Аналогично тест `throws RuntimeException with a clear message when creative id is missing` +заменяется новым `refuses to launch when a included banner has no creative number yet` — старый +удалить. + +- [ ] **Step 2: Прогнать тесты — должны упасть, и уточнить `assertSentCount`** + +Run: `cd app && php artisan test --filter=CampaignLauncher` +Expected: FAIL — объявление создаётся одно, `yandex_ad_id` у баннеров пуст. +Заодно из вывода взять фактическое число запросов и подставить его в `Http::assertSentCount(...)` +первого теста вместо предварительного `7`. + +- [ ] **Step 3: Переписать запуск под набор** + +В `app/app/Services/Advertising/CampaignLauncher.php`: + +заменить проверку креатива (строки 37–39) на проверку набора баннеров: + +```php + // Конструктор креативов Яндекса закрыт — креатив у каждого размера свой, номера + // проставляет робот-грузчик либо оператор руками. Запускаться без номеров нельзя: + // объявление уедет пустым. Основание — findings 2026-07-27. + $banners = AdCampaignBanner::where('tenant_id', $campaign->tenant_id) + ->where('campaign_id', $campaign->id) + ->where('included', true) + ->orderBy('width')->orderBy('height') + ->get(); + + if ($banners->isEmpty()) { + throw new RuntimeException('У кампании нет ни одного включённого баннера — клиенту загрузить картинки в мастере.'); + } + + $withoutCreative = $banners->firstWhere('yandex_creative_id', null); + if ($withoutCreative !== null) { + throw new RuntimeException( + "У баннера {$withoutCreative->width}×{$withoutCreative->height} нет номера креатива Яндекса — креативы ещё не загружены в кабинет." + ); + } +``` + +заменить создание единственного объявления (строка 94) на цикл: + +```php + // Одно объявление на каждый включённый баннер: у медийной кампании нет адаптивного + // креатива, каждый размер блока — своё объявление со своим креативом. + foreach ($banners as $banner) { + $adId = $direct->addCpmBannerAd($adGroupId, (int) $banner->yandex_creative_id, $href); + $banner->update([ + 'yandex_ad_id' => $adId, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + } +``` + +убрать `'yandex_ad_id' => $yandexAdId,` из `$campaign->update([...])` (строка 109) — +номера объявлений теперь у баннеров. + +Добавить импорт `use App\Models\AdCampaignBanner;`. + +- [ ] **Step 4: Прогнать тесты — должны пройти** + +Run: `cd app && php artisan test --filter=CampaignLauncher` +Expected: PASS. + +- [ ] **Step 5: Прогнать весь рекламный модуль** + +Run: `cd app && php artisan test --filter=Advertising` +Expected: PASS. Красное чинить здесь же — дальше не идти. + +- [ ] **Step 6: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Services/Advertising/CampaignLauncher.php app/tests/Feature/Advertising/CampaignLauncherTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: запуск заводит объявление на каждый включённый баннер + +Вместо единственного объявления по одному креативу кампании — цикл по набору +баннеров, номер объявления пишется на баннер. Запуск без баннеров или без номеров +креативов отклоняется с понятным сообщением. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 6: Модерация по каждому объявлению, кампания живёт при частичном отказе + +**Files:** + +- Modify: `app/app/Jobs/SyncCampaignModerationJob.php` +- Test: `app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php` (файл существует — переписать под баннеры) + +Правило из спеки §6: кампания работает, если принято **хотя бы одно** объявление; в «отклонено» +с разморозкой денег уходит, только если отклонены **все**. Сейчас один отказ валит всю кампанию — +это надо изменить, иначе один забракованный лидерборд убивает оплаченную кампанию целиком. + +- [ ] **Step 1: Написать падающие тесты** + +Дописать в `app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php`: + +```php +it('keeps the campaign running when at least one banner ad is accepted', function () { + configureYandex(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Текст на баннере', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([5551, 5552]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_RUNNING); + + $fresh = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('yandex_ad_id')->get(); + expect($fresh[0]->moderation_status)->toBe(AdCampaignBanner::MOD_ACCEPTED) + ->and($fresh[1]->moderation_status)->toBe(AdCampaignBanner::MOD_REJECTED) + ->and($fresh[1]->moderation_reason)->toBe('Текст на баннере'); +}); + +it('rejects the campaign only when every banner ad is rejected', function () { + configureYandex(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5551, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'], + ['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => 'Нельзя', 'Status' => 'REJECTED'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([5551, 5552]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_REJECTED); +}); + +it('waits while at least one banner ad is still under moderation', function () { + configureYandex(); + Http::fake(['*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 5551, 'State' => 'ON', 'StatusClarification' => null, 'Status' => 'ACCEPTED'], + ['Id' => 5552, 'State' => 'OFF', 'StatusClarification' => null, 'Status' => 'MODERATION'], + ]]])]); + + [$campaign] = makeModeratedCampaignWithBanners([5551, 5552]); + + (new SyncCampaignModerationJob)->handle(); + + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); +}); +``` + +Добавить в тот же файл фабрику: + +```php +/** + * Кампания на модерации с набором баннеров, у каждого свой номер объявления. + * + * @param list $adIds + * @return array{0: AdCampaign, 1: \Illuminate\Support\Collection} + */ +function makeModeratedCampaignWithBanners(array $adIds): array +{ + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => 222, + ]); + + $w = 300; + $banners = collect($adIds)->map(function (int $adId) use ($campaign, &$w) { + $w += 10; + + return AdCampaignBanner::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => 250, + 'path' => "ad-banners/{$campaign->tenant_id}/{$campaign->id}/{$w}x250.jpg", + 'bytes' => 1000, + 'included' => true, + 'yandex_creative_id' => 4000 + $adId, + 'yandex_ad_id' => $adId, + 'moderation_status' => AdCampaignBanner::MOD_MODERATION, + ]); + }); + + return [$campaign, $banners]; +} +``` + +- [ ] **Step 2: Прогнать тесты — должны упасть** + +Run: `cd app && php artisan test --filter=SyncCampaignModeration` +Expected: FAIL — джоб смотрит на `ad_campaign_ads`, у баннеров статусы не меняются. + +- [ ] **Step 3: Переписать джоб на баннеры** + +В `app/app/Jobs/SyncCampaignModerationJob.php` заменить блок выборки и агрегации. +Выборка кампаний остаётся через `pgsql_supplier` (BYPASSRLS), но связь — `banners`: + +```php + $campaigns = AdCampaign::on('pgsql_supplier')->whereIn('status', [ + AdCampaign::STATUS_RUNNING, + AdCampaign::STATUS_PENDING_MODERATION, + ])->whereNotNull('yandex_campaign_id')->with('banners')->get(); + + foreach ($campaigns as $campaign) { + $banners = $campaign->banners->filter(fn ($b) => $b->yandex_ad_id !== null); + + if ($banners->isEmpty()) { + continue; + } + + $statuses = $direct->getAdsModeration($banners->pluck('yandex_ad_id')->map(fn ($v) => (int) $v)->all()); + + foreach ($banners as $banner) { + $info = $statuses[(int) $banner->yandex_ad_id] ?? null; + if ($info === null) { + continue; + } + $banner->update([ + 'moderation_status' => $info['status'], + 'moderation_reason' => $info['reason'], + ]); + } + + $fresh = $banners->map(fn ($b) => $b->fresh('moderation_status')); + $anyAccepted = $fresh->contains(fn ($b) => $b->moderation_status === AdCampaignBanner::MOD_ACCEPTED); + $anyPending = $fresh->contains(fn ($b) => $b->moderation_status === AdCampaignBanner::MOD_MODERATION); + + // Правило спеки §6: кампания работает, если принято ХОТЯ БЫ ОДНО объявление — + // отклонённые просто не показываются. В «отклонено» с разморозкой денег уходим, + // только когда отклонены ВСЕ. Так не появляется пятый выход кампании: существующие + // четыре выхода снятия заморозки остаются как есть. + if ($anyPending) { + continue; // ждём, статус не трогаем + } + + if ($anyAccepted) { + $campaign->update(['status' => AdCampaign::STATUS_RUNNING]); + continue; + } + + $campaign->update(['status' => AdCampaign::STATUS_REJECTED]); + app(AdWalletService::class)->unfreezeAll((int) $campaign->tenant_id, 'campaign', (int) $campaign->id); + } +``` + +Добавить импорт `use App\Models\AdCampaignBanner;`. + +> ⚠️ Метод разморозки взять **тот же**, что вызывается в текущей версии джоба на ветке +> `rejected` — не изобретать новый. Если сигнатура отличается от показанной, оставить +> фактическую: поведение снятия заморозки менять нельзя, оно чинилось коммитом `dd894a52`. + +- [ ] **Step 4: Добавить связь banners в модель кампании** + +В `app/app/Models/AdCampaign.php` рядом с `ads()` добавить: + +```php + /** @return HasMany */ + public function banners(): HasMany + { + return $this->hasMany(AdCampaignBanner::class, 'campaign_id'); + } +``` + +- [ ] **Step 5: Прогнать тесты — должны пройти** + +Run: `cd app && php artisan test --filter=SyncCampaignModeration` +Expected: PASS. + +- [ ] **Step 6: Прогнать весь рекламный модуль** + +Run: `cd app && php artisan test --filter=Advertising` +Expected: PASS. + +- [ ] **Step 7: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Jobs/SyncCampaignModerationJob.php app/app/Models/AdCampaign.php app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: модерация по каждому баннеру, кампания живёт при частичном отказе + +Раньше один отклонённый баннер валил всю оплаченную кампанию. Теперь кампания +работает, если принято хотя бы одно объявление, а в отклонённые с разморозкой +денег уходит только когда отклонены все. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +# ФАЗА 2 — очередь заданий и канал робота + +## Task 7: Таблица заданий робота + +**Files:** + +- Create: `app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php` +- Create: `app/app/Models/AdCreativeJob.php` +- Modify: `db/CHANGELOG_schema.md` +- Test: `app/tests/Feature/Advertising/AdCreativeJobModelTest.php` + +- [ ] **Step 1: Написать падающий тест** + +Создать `app/tests/Feature/Advertising/AdCreativeJobModelTest.php`: + +```php +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + $job = AdCreativeJob::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'snapshot_before' => [111 => [300, 250]], + ]); + + $fresh = $job->fresh(); + expect($fresh->status)->toBe('queued') + ->and($fresh->attempts)->toBe(0) + ->and($fresh->snapshot_before)->toBe(['111' => [300, 250]]) + ->and($fresh->failure_reason)->toBeNull(); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd app && php artisan test --filter=AdCreativeJobModel` +Expected: FAIL — класса и таблицы нет. + +- [ ] **Step 3: Написать миграцию** + +Создать `app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php`: + +```php +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('ad_campaigns')->cascadeOnDelete(); + // queued → taken → done | failed + $table->string('status', 16)->default('queued'); + $table->unsignedSmallInteger('attempts')->default(0); + // Слепок номеров картиночных креативов аккаунта ДО загрузки: номер → [ш, в]. + $table->jsonb('snapshot_before')->nullable(); + $table->string('failure_reason', 1024)->nullable(); + $table->timestamp('taken_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + $table->index('status'); + }); + + DB::statement('ALTER TABLE ad_creative_jobs ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE ad_creative_jobs FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON ad_creative_jobs'); + DB::statement("CREATE POLICY tenant_isolation ON ad_creative_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_creative_jobs TO crm_app_user'); + // Канал робота ходит без tenant-контекста (сервис-токен, не пользователь) — читает и + // пишет через BYPASSRLS-соединение, как джобы рекламы. + DB::statement('GRANT SELECT, INSERT, UPDATE ON ad_creative_jobs TO crm_supplier_worker'); + } + + public function down(): void + { + Schema::dropIfExists('ad_creative_jobs'); + } +}; +``` + +- [ ] **Step 4: Написать модель** + +Создать `app/app/Models/AdCreativeJob.php`: + +```php + 'integer', + 'campaign_id' => 'integer', + 'attempts' => 'integer', + 'snapshot_before' => 'array', + 'taken_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function campaign(): BelongsTo + { + return $this->belongsTo(AdCampaign::class, 'campaign_id'); + } +} +``` + +- [ ] **Step 5: Применить и прогнать тест** + +Run: + +```bash +cd app && DB_DATABASE=liderra_testing php artisan migrate --force && php artisan test --filter=AdCreativeJobModel +``` + +Expected: PASS. + +- [ ] **Step 6: Записать в CHANGELOG схемы и прогнать rls-reviewer** + +Дописать в `db/CHANGELOG_schema.md` запись v9.04 про `ad_creative_jobs` с политикой +`tenant_isolation` и GRANT'ами, с пометкой **🔴 после выката перезапустить +`db/03_service_bypass_policies.sql`**. Затем вызвать агента `rls-reviewer` на новую миграцию +и исправить замечания. + +- [ ] **Step 7: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/database/migrations/2026_07_27_110000_create_ad_creative_jobs.php app/app/Models/AdCreativeJob.php app/tests/Feature/Advertising/AdCreativeJobModelTest.php db/CHANGELOG_schema.md +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: очередь заданий робота-грузчика креативов + +Одно задание — один набор баннеров одной кампании. Робот работает по одному +заданию за раз, иначе слепки креативов до и после перемешаются. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 8: Сервис заданий — постановка, выдача, приём результата + +**Files:** + +- Create: `app/app/Services/Advertising/CreativeJobService.php` +- Test: `app/tests/Feature/Advertising/CreativeJobServiceTest.php` + +- [ ] **Step 1: Написать падающие тесты** + +Создать `app/tests/Feature/Advertising/CreativeJobServiceTest.php`: + +```php +create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'C', + 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, + 'client_cpm_rub' => '120.00', + ]); + + foreach ($sizes as [$w, $h]) { + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'width' => $w, + 'height' => $h, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/{$w}x{$h}.jpg", + 'bytes' => 1000, + 'included' => true, + ]); + } + + return $campaign; +} + +it('enqueues a job with the current creatives snapshot', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => [ + ['Id' => 111, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]])]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + + $job = app(CreativeJobService::class)->enqueue($campaign); + + expect($job->status)->toBe(AdCreativeJob::STATUS_QUEUED) + ->and($job->snapshot_before)->toBe(['111' => [300, 250]]); +}); + +it('does not enqueue a second job while one is already waiting', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $campaign = makeCampaignWithBanners([[300, 250]]); + + $first = app(CreativeJobService::class)->enqueue($campaign); + $second = app(CreativeJobService::class)->enqueue($campaign); + + expect($second->id)->toBe($first->id) + ->and(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1); +}); + +it('gives the robot one job at a time and marks it taken', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $a = makeCampaignWithBanners([[300, 250]]); + $b = makeCampaignWithBanners([[728, 90]]); + app(CreativeJobService::class)->enqueue($a); + app(CreativeJobService::class)->enqueue($b); + + $taken = app(CreativeJobService::class)->takeNext(); + expect($taken->status)->toBe(AdCreativeJob::STATUS_TAKEN) + ->and($taken->attempts)->toBe(1); + + // Пока первое задание не завершено, второе не выдаётся — робот работает по одному. + expect(app(CreativeJobService::class)->takeNext())->toBeNull(); +}); + +it('writes creative numbers onto banners when the robot reports done', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) // слепок «до» + ->push(['result' => ['Creatives' => [ // слепок «после» + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ['Id' => 556, 'Type' => 'HTML5_CREATIVE', 'Width' => 728, 'Height' => 90], + ]]]), + ]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + app(CreativeJobService::class)->complete($job->fresh()); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->orderBy('width')->get(); + expect($banners[0]->yandex_creative_id)->toBe(555) + ->and($banners[1]->yandex_creative_id)->toBe(556) + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); + +it('fails the job and touches no banner when the snapshot does not add up', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['result' => ['Creatives' => [ + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + ]); + + $campaign = makeCampaignWithBanners([[300, 250], [728, 90]]); + $job = app(CreativeJobService::class)->enqueue($campaign); + app(CreativeJobService::class)->takeNext(); + + expect(fn () => app(CreativeJobService::class)->complete($job->fresh())) + ->toThrow(CreativeMatchFailedException::class); + + $banners = AdCampaignBanner::where('campaign_id', $campaign->id)->get(); + expect($banners->pluck('yandex_creative_id')->filter())->toBeEmpty() + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toContain('728x90'); +}); +``` + +- [ ] **Step 2: Прогнать тесты — должны упасть** + +Run: `cd app && php artisan test --filter=CreativeJobService` +Expected: FAIL — класса нет. + +- [ ] **Step 3: Написать сервис** + +Создать `app/app/Services/Advertising/CreativeJobService.php`: + +```php +id) + ->whereIn('status', [AdCreativeJob::STATUS_QUEUED, AdCreativeJob::STATUS_TAKEN]) + ->first(); + + if ($pending !== null) { + return $pending; + } + + return AdCreativeJob::create([ + 'tenant_id' => $campaign->tenant_id, + 'campaign_id' => $campaign->id, + 'status' => AdCreativeJob::STATUS_QUEUED, + 'snapshot_before' => $this->client()->listImageCreativeIds(), + ]); + } + + /** Выдаёт роботу одно задание. Null, если очередь пуста или одно уже в работе. */ + public function takeNext(): ?AdCreativeJob + { + return DB::transaction(function () { + $inFlight = AdCreativeJob::where('status', AdCreativeJob::STATUS_TAKEN)->exists(); + if ($inFlight) { + return null; + } + + $job = AdCreativeJob::where('status', AdCreativeJob::STATUS_QUEUED) + ->orderBy('id')->lockForUpdate()->first(); + + if ($job === null) { + return null; + } + + $job->update([ + 'status' => AdCreativeJob::STATUS_TAKEN, + 'attempts' => $job->attempts + 1, + 'taken_at' => now(), + ]); + + return $job->fresh(); + }); + } + + /** + * Принимает отчёт робота «готово»: берёт слепок «после», сопоставляет и проставляет + * номера креативов баннерам. При несовпадении не трогает ни один баннер. + * + * @throws CreativeMatchFailedException + */ + public function complete(AdCreativeJob $job): void + { + $banners = AdCampaignBanner::where('campaign_id', $job->campaign_id) + ->where('included', true) + ->orderBy('width')->orderBy('height') + ->get(); + + $expected = $banners->map(fn ($b) => [(int) $b->width, (int) $b->height])->all(); + $before = $this->normalizeSnapshot($job->snapshot_before ?? []); + $after = $this->client()->listImageCreativeIds(); + + try { + $matched = $this->matcher->match($before, $after, $expected); + } catch (CreativeMatchFailedException $e) { + $job->update([ + 'status' => AdCreativeJob::STATUS_FAILED, + 'failure_reason' => mb_substr($e->getMessage(), 0, 1024), + 'finished_at' => now(), + ]); + throw $e; + } + + DB::transaction(function () use ($banners, $matched, $job) { + foreach ($banners as $banner) { + $banner->update(['yandex_creative_id' => $matched[$banner->width.'x'.$banner->height]]); + } + $job->update(['status' => AdCreativeJob::STATUS_DONE, 'finished_at' => now()]); + }); + } + + /** Помечает задание сбойным по причине от робота. */ + public function fail(AdCreativeJob $job, string $reason): void + { + $job->update([ + 'status' => AdCreativeJob::STATUS_FAILED, + 'failure_reason' => mb_substr($reason, 0, 1024), + 'finished_at' => now(), + ]); + } + + /** + * JSON возвращает ключи строками — приводим обратно к int, чтобы array_diff_key + * в CreativeIdMatcher сравнивал одинаковые типы ключей. + * + * @param array $snapshot + * @return array + */ + private function normalizeSnapshot(array $snapshot): array + { + $out = []; + foreach ($snapshot as $id => $size) { + $out[(int) $id] = [(int) $size[0], (int) $size[1]]; + } + + return $out; + } + + private function client(): YandexDirectClient + { + return new YandexDirectClient( + (string) config('services.yandex_direct.base_url'), + (string) config('services.yandex_direct.token'), + ); + } +} +``` + +- [ ] **Step 4: Прогнать тесты — должны пройти** + +Run: `cd app && php artisan test --filter=CreativeJobService` +Expected: PASS, 5 тестов. + +- [ ] **Step 5: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Services/Advertising/CreativeJobService.php app/tests/Feature/Advertising/CreativeJobServiceTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: сервис заданий робота со слепками до и после + +Задания выдаются строго по одному. При несовпадении слепка ни один баннер не +трогается, задание уходит в сбой с понятной причиной. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 9: Канал робота — токен, три адреса, маршруты + +**Files:** + +- Create: `app/app/Http/Middleware/CreativeRobotToken.php` +- Create: `app/app/Http/Controllers/Api/CreativeRobotController.php` +- Modify: `app/bootstrap/app.php`, `app/routes/web.php`, `app/config/services.php`, `app/.env.example` +- Test: `app/tests/Feature/Advertising/CreativeRobotEndpointTest.php` + +- [ ] **Step 1: Написать падающие тесты** + +Создать `app/tests/Feature/Advertising/CreativeRobotEndpointTest.php`: + +```php + 'ROBOTSECRET']); + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); +}); + +it('rejects a request without the robot token', function () { + $this->getJson('/api/creative-robot/next')->assertStatus(401); +}); + +it('rejects a request with a wrong robot token', function () { + $this->withHeader('X-Creative-Robot-Token', 'nope') + ->getJson('/api/creative-robot/next') + ->assertStatus(401); +}); + +it('returns nothing to do when the queue is empty', function () { + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk() + ->assertJson(['job' => null]); +}); + +it('hands out a job with the list of banner files to upload', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + Storage::fake('local'); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, 'client_cpm_rub' => '120.00', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, + 'path' => "ad-banners/{$tenant->id}/{$campaign->id}/300x250.jpg", + 'bytes' => 1000, 'included' => true, + ]); + app(App\Services\Advertising\CreativeJobService::class)->enqueue($campaign); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->getJson('/api/creative-robot/next') + ->assertOk() + ->assertJsonPath('job.campaign_id', $campaign->id) + ->assertJsonPath('job.banners.0.width', 300) + ->assertJsonPath('job.banners.0.height', 250) + ->assertJsonStructure(['job' => ['id', 'campaign_id', 'banners' => [['banner_id', 'width', 'height', 'file_url']]]]); +}); + +it('serves a banner file to the robot', function () { + Storage::fake('local'); + Storage::disk('local')->put('ad-banners/1/2/300x250.jpg', 'BINARY'); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, 'client_cpm_rub' => '120.00', + ]); + $banner = AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'path' => 'ad-banners/1/2/300x250.jpg', + 'bytes' => 6, 'included' => true, + ]); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->get("/api/creative-robot/banners/{$banner->id}/file") + ->assertOk(); +}); + +it('accepts the done report and writes creative numbers', function () { + Http::fake(['*/json/v5/creatives' => Http::sequence() + ->push(['result' => ['Creatives' => []]]) + ->push(['result' => ['Creatives' => [ + ['Id' => 555, 'Type' => 'HTML5_CREATIVE', 'Width' => 300, 'Height' => 250], + ]]]), + ]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, 'client_cpm_rub' => '120.00', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'path' => 'p.jpg', 'bytes' => 1, 'included' => true, + ]); + $job = app(App\Services\Advertising\CreativeJobService::class)->enqueue($campaign); + app(App\Services\Advertising\CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => true]) + ->assertOk(); + + expect(AdCampaignBanner::where('campaign_id', $campaign->id)->first()->yandex_creative_id)->toBe(555) + ->and($job->fresh()->status)->toBe(AdCreativeJob::STATUS_DONE); +}); + +it('accepts a failure report from the robot', function () { + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + $tenant = Tenant::factory()->create(); + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, 'name' => 'C', 'mode' => AdCampaign::MODE_MANUAL, + 'audience_days' => 10, 'client_cpm_rub' => '120.00', + ]); + AdCampaignBanner::create([ + 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, + 'width' => 300, 'height' => 250, 'path' => 'p.jpg', 'bytes' => 1, 'included' => true, + ]); + $job = app(App\Services\Advertising\CreativeJobService::class)->enqueue($campaign); + app(App\Services\Advertising\CreativeJobService::class)->takeNext(); + + $this->withHeader('X-Creative-Robot-Token', 'ROBOTSECRET') + ->postJson("/api/creative-robot/jobs/{$job->id}/done", ['ok' => false, 'reason' => 'вход слетел']) + ->assertOk(); + + expect($job->fresh()->status)->toBe(AdCreativeJob::STATUS_FAILED) + ->and($job->fresh()->failure_reason)->toBe('вход слетел'); +}); +``` + +- [ ] **Step 2: Прогнать тесты — должны упасть** + +Run: `cd app && php artisan test --filter=CreativeRobotEndpoint` +Expected: FAIL — маршрутов нет, 404. + +- [ ] **Step 3: Написать middleware** + +Создать `app/app/Http/Middleware/CreativeRobotToken.php`: + +```php +header('X-Creative-Robot-Token', ''); + + if ($expected === '' || ! hash_equals($expected, $given)) { + abort(401, 'Неверный сервис-токен робота.'); + } + + return $next($request); + } +} +``` + +- [ ] **Step 4: Зарегистрировать алиас и конфиг** + +В `app/bootstrap/app.php` добавить импорт `use App\Http\Middleware\CreativeRobotToken;` +и в массив алиасов рядом с `'sales-integration'`: + +```php + 'creative-robot' => CreativeRobotToken::class, +``` + +В `app/config/services.php` добавить блок: + +```php + // Канал робота-грузчика креативов в веб-кабинет Яндекса. Токен общий с роботом, + // отдельный от клиентских. Пусто → канал закрыт. + 'creative_robot' => [ + 'token' => env('CREATIVE_ROBOT_TOKEN', ''), + ], +``` + +В `app/.env.example` добавить строку `CREATIVE_ROBOT_TOKEN=`. + +- [ ] **Step 5: Написать контроллер** + +Создать `app/app/Http/Controllers/Api/CreativeRobotController.php`: + +```php +jobs->takeNext(); + + if ($job === null) { + return response()->json(['job' => null]); + } + + $banners = AdCampaignBanner::on('pgsql_supplier') + ->where('campaign_id', $job->campaign_id) + ->where('included', true) + ->orderBy('width')->orderBy('height') + ->get(); + + return response()->json(['job' => [ + 'id' => $job->id, + 'campaign_id' => $job->campaign_id, + 'banners' => $banners->map(fn ($b) => [ + 'banner_id' => $b->id, + 'width' => (int) $b->width, + 'height' => (int) $b->height, + 'file_url' => url("/api/creative-robot/banners/{$b->id}/file"), + ])->values()->all(), + ]]); + } + + /** Отдать роботу файл баннера. */ + public function file(int $bannerId): StreamedResponse + { + $banner = AdCampaignBanner::on('pgsql_supplier')->findOrFail($bannerId); + + abort_unless(Storage::disk('local')->exists($banner->path), 404, 'Файл баннера не найден.'); + + return Storage::disk('local')->download($banner->path, "{$banner->width}x{$banner->height}.jpg"); + } + + /** Принять отчёт робота: готово или сбой. */ + public function done(Request $request, int $jobId): JsonResponse + { + $data = $request->validate([ + 'ok' => ['required', 'boolean'], + 'reason' => ['nullable', 'string', 'max:1024'], + ]); + + $job = AdCreativeJob::on('pgsql_supplier')->findOrFail($jobId); + + if ($data['ok'] === false) { + $this->jobs->fail($job, (string) ($data['reason'] ?? 'Робот не сообщил причину.')); + + return response()->json(['status' => AdCreativeJob::STATUS_FAILED]); + } + + try { + $this->jobs->complete($job); + } catch (CreativeMatchFailedException $e) { + // Задание уже помечено сбойным внутри complete(); роботу отвечаем понятно, + // но 200 — он свою работу сделал, разошёлся слепок, это наша сторона. + return response()->json(['status' => AdCreativeJob::STATUS_FAILED, 'message' => $e->getMessage()]); + } + + return response()->json(['status' => AdCreativeJob::STATUS_DONE]); + } +} +``` + +- [ ] **Step 6: Добавить маршруты** + +В `app/routes/web.php` рядом с группой `api/sales/integration` добавить: + +```php +// Сервис-канал «Робот-грузчик креативов → Портал». Токен вместо пользователя. +// admin-db — робот ходит без tenant-контекста, пишет через служебное соединение. +Route::middleware(['admin-db', 'creative-robot'])->prefix('api/creative-robot')->group(function () { + Route::get('/next', [CreativeRobotController::class, 'next']); + Route::get('/banners/{bannerId}/file', [CreativeRobotController::class, 'file'])->whereNumber('bannerId'); + Route::post('/jobs/{jobId}/done', [CreativeRobotController::class, 'done'])->whereNumber('jobId'); +}); +``` + +Добавить импорт `use App\Http\Controllers\Api\CreativeRobotController;` в шапку файла. + +- [ ] **Step 7: Прогнать тесты — должны пройти** + +Run: `cd app && php artisan test --filter=CreativeRobotEndpoint` +Expected: PASS, 7 тестов. + +- [ ] **Step 8: Прогнать весь рекламный модуль** + +Run: `cd app && php artisan test --filter=Advertising` +Expected: PASS. + +- [ ] **Step 9: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Http/Middleware/CreativeRobotToken.php app/app/Http/Controllers/Api/CreativeRobotController.php app/bootstrap/app.php app/routes/web.php app/config/services.php app/.env.example app/tests/Feature/Advertising/CreativeRobotEndpointTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: служебный канал робота-грузчика креативов + +Три адреса под своим токеном: взять задание, скачать файл баннера, отчитаться. +Запустить кампанию через этот канал нельзя — таких действий в нём нет. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 10: Кампания сама встаёт в очередь к роботу + +**Files:** + +- Modify: `app/app/Http/Controllers/Api/AdvertisingCampaignController.php` +- Test: `app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php` + +Клиент жмёт «Запустить». Если у включённых баннеров ещё нет номеров креативов — вместо ошибки +ставим задание роботу и отвечаем «креативы готовятся». Когда номера появятся, клиент жмёт +«Запустить» снова, либо запуск идёт автоматически — второе вне этой задачи. + +- [ ] **Step 1: Написать падающие тесты** + +Дописать в `app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php`: + +```php +it('queues a creative job instead of failing when banners have no creative numbers', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + // Кампания с готовым баннером, но без номера креатива. + [$user, $campaign] = makeCampaignReadyToLaunchWithoutCreatives(); + + $this->actingAs($user) + ->postJson("/api/advertising/campaigns/{$campaign->id}/launch") + ->assertStatus(202) + ->assertJsonPath('status', 'creatives_pending'); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1) + ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + +it('does not queue a second creative job on repeated launch', function () { + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'T']); + config(['services.yandex_direct.base_url' => 'https://api.direct.yandex.com']); + Http::fake(['*/json/v5/creatives' => Http::response(['result' => ['Creatives' => []]])]); + + [$user, $campaign] = makeCampaignReadyToLaunchWithoutCreatives(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202); + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/launch")->assertStatus(202); + + expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(1); +}); +``` + +Фабрику `makeCampaignReadyToLaunchWithoutCreatives()` написать по образцу уже существующих +в этом файле фабрик кампании: тенант + пользователь + кампания в `draft` с заполненными +`landing_url`, `estimated_impressions`, пополненным кошельком, аудиторией ≥100 и одним +включённым баннером 300×250 **без** `yandex_creative_id`. + +- [ ] **Step 2: Прогнать тесты — должны упасть** + +Run: `cd app && php artisan test --filter=AdvertisingCampaignEndpoint` +Expected: FAIL — запуск отдаёт ошибку про отсутствие номера креатива, задание не ставится. + +- [ ] **Step 3: Поправить запуск в контроллере** + +В методе запуска `AdvertisingCampaignController` перед вызовом `CampaignLauncher::launch()` +добавить проверку и постановку задания: + +```php + // Креативы в кабинет Яндекса заливает робот-грузчик: через API картиночный креатив + // не создать. Если номеров ещё нет — не ошибка, а «подождите»: ставим задание роботу + // и отвечаем 202. Кампания остаётся черновиком, деньги не морозятся. + $needCreatives = AdCampaignBanner::where('tenant_id', $tenantId) + ->where('campaign_id', $campaign->id) + ->where('included', true) + ->whereNull('yandex_creative_id') + ->exists(); + + if ($needCreatives) { + app(CreativeJobService::class)->enqueue($campaign); + + return response()->json([ + 'status' => 'creatives_pending', + 'message' => 'Готовим картинки в рекламном кабинете. Обычно занимает несколько минут — попробуйте запустить чуть позже.', + ], 202); + } +``` + +Добавить импорты `use App\Models\AdCampaignBanner;` и `use App\Services\Advertising\CreativeJobService;`. + +- [ ] **Step 4: Прогнать тесты — должны пройти** + +Run: `cd app && php artisan test --filter=AdvertisingCampaignEndpoint` +Expected: PASS. + +- [ ] **Step 5: Прогнать весь рекламный модуль** + +Run: `cd app && php artisan test --filter=Advertising` +Expected: PASS. + +- [ ] **Step 6: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add app/app/Http/Controllers/Api/AdvertisingCampaignController.php app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat реклама показы: запуск без номеров креативов ставит задание роботу + +Вместо ошибки клиент получает понятное «готовим картинки, попробуйте позже», а +кампания остаётся черновиком и деньги не морозятся. Повторный запуск второго +задания не плодит. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +# ФАЗА 3 — робот + +## Task 11: Разметка кабинета — снять реальные экраны загрузки + +**Files:** + +- Create: `bots/yandex-creatives/docs/cabinet-flow.md` + +Селекторы нельзя выдумать — их надо снять с живого кабинета, как делалось для МТС-бота. +Эта задача **не пишет код**, она добывает факты для Task 14. + +- [ ] **Step 1: Открыть кабинет и дойти до загрузки креатива** + +Через Playwright MCP: `https://direct.yandex.ru` → «Кампании» → медийная кампания → +«Редактировать» у объявления → блок «Креатив» → кнопка `[data-testid="ImageCreativeEditor.OpenMenu"]` +→ пункт **«Загрузить креативы»**. + +⚠️ **Ничего не сохранять.** Кампания живая, крутится за деньги. Уходить со страницы без +нажатия «Сохранить изменения». + +- [ ] **Step 2: Записать каждый экран** + +Для каждого шага занести в `bots/yandex-creatives/docs/cabinet-flow.md`: + +- адрес страницы; +- снимок экрана в `docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/`; +- **устойчивый селектор** каждой кнопки/поля — предпочитать `data-testid`, затем роль+имя, + в последнюю очередь текст; +- что означает успех шага (какой элемент появляется); +- сколько ждать. + +- [ ] **Step 3: Отдельно записать, как выглядит «вход слетел»** + +Зафиксировать признак авторизованного кабинета — по образцу `bots/mts-telegram-ads/src/session.js`, +где признаком служит устойчивый пункт меню. Для Директа кандидат — блок баланса в боковом меню +(кнопка с суммой и «Пополнить»). Проверить, что он отсутствует на странице входа. + +- [ ] **Step 4: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add bots/yandex-creatives/docs/cabinet-flow.md docs/superpowers/findings/2026-07-27-yandex-konstruktor-zakryt/ +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +docs робот креативов: разметка экранов загрузки в кабинете Директа + +Селекторы сняты с живого кабинета, ничего не сохранялось. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 12: Скелет робота и конфиг + +**Files:** + +- Create: `bots/yandex-creatives/package.json` +- Create: `bots/yandex-creatives/src/config.js` +- Create: `bots/yandex-creatives/.env.example` +- Create: `bots/yandex-creatives/.gitignore` +- Test: `bots/yandex-creatives/test/config.test.js` + +- [ ] **Step 1: Написать падающий тест** + +Создать `bots/yandex-creatives/test/config.test.js`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; + +const full = { + YC_BROWSER_PROFILE_DIR: '/var/lib/liderra-robot/profile', + YC_CABINET_URL: 'https://direct.yandex.ru', + PORTAL_BASE_URL: 'https://lk.liderra.ru', + CREATIVE_ROBOT_TOKEN: 'secret', + SMTP_HOST: 'smtp.example', SMTP_PORT: '587', SMTP_USER: 'u', SMTP_PASS: 'p', + ALARM_FROM: 'bot@liderra.ru', ALARM_TO: 'ops@liderra.ru', +}; + +test('загружает полный конфиг', () => { + const c = loadConfig(full); + assert.equal(c.portalBaseUrl, 'https://lk.liderra.ru'); + assert.equal(c.robotToken, 'secret'); + assert.equal(c.humanDelayMs, 800); +}); + +test('падает с понятным сообщением, если нет обязательной переменной', () => { + const { CREATIVE_ROBOT_TOKEN, ...without } = full; + assert.throws(() => loadConfig(without), /CREATIVE_ROBOT_TOKEN/); +}); + +test('срезает хвостовой слеш у адреса портала, чтобы не собирать двойные слеши', () => { + assert.equal(loadConfig({ ...full, PORTAL_BASE_URL: 'https://lk.liderra.ru/' }).portalBaseUrl, 'https://lk.liderra.ru'); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd bots/yandex-creatives && node --test` +Expected: FAIL — модуля нет. + +- [ ] **Step 3: Написать package.json** + +```json +{ + "name": "yandex-creatives-robot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "run:once": "node bin/run.js", + "keepalive": "node bin/keepalive.js", + "login": "node bin/login.js" + }, + "dependencies": { + "playwright": "^1.47.0", + "nodemailer": "^6.9.0", + "dotenv": "^16.4.0" + } +} +``` + +- [ ] **Step 4: Написать конфиг** + +Создать `bots/yandex-creatives/src/config.js`: + +```js +function required(env, key) { + const v = env[key]; + if (v === undefined || v === '') throw new Error(`Не задана переменная окружения: ${key}`); + return v; +} + +export function loadConfig(env = process.env) { + return { + profileDir: required(env, 'YC_BROWSER_PROFILE_DIR'), + cabinetUrl: required(env, 'YC_CABINET_URL'), + // Портал, у которого робот спрашивает работу и которому отчитывается. + portalBaseUrl: required(env, 'PORTAL_BASE_URL').replace(/\/+$/, ''), + robotToken: required(env, 'CREATIVE_ROBOT_TOKEN'), + smtp: { + host: required(env, 'SMTP_HOST'), + port: Number(required(env, 'SMTP_PORT')), + user: required(env, 'SMTP_USER'), + pass: required(env, 'SMTP_PASS'), + }, + alarmFrom: required(env, 'ALARM_FROM'), + alarmTo: required(env, 'ALARM_TO'), + // Человекоподобный темп: кабинет не должен видеть машинную скорость. + humanDelayMs: Number(env.HUMAN_DELAY_MS ?? '800'), + }; +} +``` + +- [ ] **Step 5: Написать .env.example и .gitignore** + +`bots/yandex-creatives/.env.example`: + +``` +YC_BROWSER_PROFILE_DIR=/var/lib/liderra-creative-robot/profile +YC_CABINET_URL=https://direct.yandex.ru +PORTAL_BASE_URL=https://lk.liderra.ru +CREATIVE_ROBOT_TOKEN= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +ALARM_FROM=robot@liderra.ru +ALARM_TO=ops@liderra.ru +HUMAN_DELAY_MS=800 +``` + +`bots/yandex-creatives/.gitignore`: + +``` +node_modules/ +screenshots/ +downloads/ +.env +``` + +- [ ] **Step 6: Прогнать тест — должен пройти** + +Run: `cd bots/yandex-creatives && npm install && node --test` +Expected: PASS, 3 теста. + +- [ ] **Step 7: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add bots/yandex-creatives/package.json bots/yandex-creatives/package-lock.json bots/yandex-creatives/src/config.js bots/yandex-creatives/test/config.test.js bots/yandex-creatives/.env.example bots/yandex-creatives/.gitignore +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat робот креативов: скелет проекта и конфиг + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 13: Разговор с порталом + +**Files:** + +- Create: `bots/yandex-creatives/src/portal.js` +- Test: `bots/yandex-creatives/test/portal.test.js` + +- [ ] **Step 1: Написать падающий тест** + +Создать `bots/yandex-creatives/test/portal.test.js`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPortal } from '../src/portal.js'; + +const config = { portalBaseUrl: 'https://lk.liderra.ru', robotToken: 'secret' }; + +test('спрашивает работу и возвращает задание', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ job: { id: 7, campaign_id: 42, banners: [] } }) }; + }; + + const portal = createPortal(config, fetchStub); + const job = await portal.takeJob(); + + assert.equal(job.id, 7); + assert.equal(calls[0].url, 'https://lk.liderra.ru/api/creative-robot/next'); + assert.equal(calls[0].opts.headers['X-Creative-Robot-Token'], 'secret'); +}); + +test('возвращает null, когда работы нет', async () => { + const fetchStub = async () => ({ ok: true, status: 200, json: async () => ({ job: null }) }); + assert.equal(await createPortal(config, fetchStub).takeJob(), null); +}); + +test('бросает понятную ошибку при отказе портала', async () => { + const fetchStub = async () => ({ ok: false, status: 401, json: async () => ({}) }); + await assert.rejects(() => createPortal(config, fetchStub).takeJob(), /401/); +}); + +test('отчитывается об успехе', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'done' }) }; + }; + + await createPortal(config, fetchStub).reportDone(7); + + assert.equal(calls[0].url, 'https://lk.liderra.ru/api/creative-robot/jobs/7/done'); + assert.equal(JSON.parse(calls[0].opts.body).ok, true); +}); + +test('отчитывается о сбое с причиной', async () => { + const calls = []; + const fetchStub = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, status: 200, json: async () => ({ status: 'failed' }) }; + }; + + await createPortal(config, fetchStub).reportFailure(7, 'вход слетел'); + + const body = JSON.parse(calls[0].opts.body); + assert.equal(body.ok, false); + assert.equal(body.reason, 'вход слетел'); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd bots/yandex-creatives && node --test` +Expected: FAIL — модуля нет. + +- [ ] **Step 3: Написать клиент портала** + +Создать `bots/yandex-creatives/src/portal.js`: + +```js +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +/** + * Разговор робота с порталом. fetch передаётся снаружи — так его можно подменить в тестах. + */ +export function createPortal(config, fetchImpl = fetch) { + const headers = { 'X-Creative-Robot-Token': config.robotToken, Accept: 'application/json' }; + + async function call(path, init = {}) { + const res = await fetchImpl(`${config.portalBaseUrl}${path}`, { ...init, headers: { ...headers, ...(init.headers ?? {}) } }); + if (!res.ok) throw new Error(`Портал ответил ${res.status} на ${path}`); + return res.json(); + } + + return { + /** Забрать одно задание. null — работы нет. */ + async takeJob() { + const data = await call('/api/creative-robot/next'); + return data.job ?? null; + }, + + /** Скачать файл баннера на диск. */ + async downloadBanner(banner, targetPath) { + const res = await fetchImpl(banner.file_url, { headers }); + if (!res.ok) throw new Error(`Не скачался файл баннера ${banner.width}x${banner.height}: ${res.status}`); + await mkdir(dirname(targetPath), { recursive: true }); + await pipeline(Readable.fromWeb(res.body), createWriteStream(targetPath)); + return targetPath; + }, + + async reportDone(jobId) { + return call(`/api/creative-robot/jobs/${jobId}/done`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ok: true }), + }); + }, + + async reportFailure(jobId, reason) { + return call(`/api/creative-robot/jobs/${jobId}/done`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ok: false, reason }), + }); + }, + }; +} +``` + +- [ ] **Step 4: Прогнать тест — должен пройти** + +Run: `cd bots/yandex-creatives && node --test` +Expected: PASS, 8 тестов суммарно. + +- [ ] **Step 5: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add bots/yandex-creatives/src/portal.js bots/yandex-creatives/test/portal.test.js +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat робот креативов: разговор с порталом — взять задание, скачать файлы, отчитаться + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 14: Браузер, проверка входа, загрузка в кабинет + +**Files:** + +- Create: `bots/yandex-creatives/src/browser.js` +- Create: `bots/yandex-creatives/src/session.js` +- Create: `bots/yandex-creatives/src/cabinet.js` +- Create: `bots/yandex-creatives/bin/login.js` +- Create: `bots/yandex-creatives/bin/keepalive.js` + +Селекторы берутся **из `docs/cabinet-flow.md`** (Task 11). Ниже — каркас; конкретные селекторы +подставляются из разметки. + +- [ ] **Step 1: Написать браузер** + +Создать `bots/yandex-creatives/src/browser.js`: + +```js +import { chromium } from 'playwright'; + +/** + * Персистентный профиль браузера робота: вход в кабинет заводится один раз глазами + * (bin/login.js через удалённый рабочий стол на боевом сервере) и живёт на диске. + * + * headless по умолчанию true — на боевом экрана нет; для входа bin/login.js + * запускается с headless:false внутри виртуального экрана. + */ +export async function openBrowser(config, { headless = true } = {}) { + const context = await chromium.launchPersistentContext(config.profileDir, { + headless, + viewport: { width: 1440, height: 900 }, + locale: 'ru-RU', + ignoreDefaultArgs: ['--enable-automation'], + args: ['--disable-blink-features=AutomationControlled'], + }); + const page = context.pages()[0] ?? (await context.newPage()); + return { context, page }; +} + +export async function humanPause(config) { + await new Promise((r) => setTimeout(r, config.humanDelayMs)); +} +``` + +- [ ] **Step 2: Написать проверку входа** + +Создать `bots/yandex-creatives/src/session.js` — признак берётся из `docs/cabinet-flow.md` шаг 3: + +```js +/** + * Признак «залогинен» в Директе — блок баланса в боковом меню (кнопка с суммой + * и «Пополнить»). На странице входа его нет. Точный селектор — docs/cabinet-flow.md. + */ +export async function isLoggedIn(page, config) { + await page.goto(config.cabinetUrl, { waitUntil: 'domcontentloaded' }).catch(() => {}); + const balance = page.getByRole('button', { name: /Пополнить/ }); + try { + await balance.waitFor({ timeout: 8000 }); + return true; + } catch { + return false; + } +} +``` + +- [ ] **Step 3: Написать загрузчик креативов** + +Создать `bots/yandex-creatives/src/cabinet.js`. Каркас — селекторы подставить из разметки: + +```js +import { humanPause } from './browser.js'; + +/** + * Загрузка готовых файлов как креативов в кабинете Директа. + * + * Конструктор креативов закрыт 01.06.2026 — доступен только пункт «Загрузить креативы» + * в меню креатива объявления (кнопка data-testid ImageCreativeEditor.OpenMenu). + * Точная последовательность экранов — docs/cabinet-flow.md. + * + * Робот НЕ читает номера созданных креативов: их портал добывает слепком creatives.get. + */ +export async function uploadCreatives(page, config, files) { + await page.goto(`${config.cabinetUrl}/dna/grid/campaigns`, { waitUntil: 'domcontentloaded' }); + await humanPause(config); + + // ШАГ 1 — открыть форму загрузки креативов (селектор из cabinet-flow.md §1). + // ШАГ 2 — на каждый файл: setInputFiles + дождаться подтверждения (§2). + // ШАГ 3 — убедиться, что все файлы приняты; при отказе Яндекса бросить Error + // с текстом сообщения кабинета, чтобы сторож положил его в письмо (§3). + // + // ⚠️ Заглушка до доразметки: пока cabinet-flow.md не заполнен, штатно бросаем — + // сторож пришлёт алярм, обходить заглушку нельзя (тот же приём, что в МТС-боте). + throw new Error('Шаг «Загрузить креативы» ещё не размечен — заполнить docs/cabinet-flow.md и подставить селекторы'); +} +``` + +- [ ] **Step 4: Написать вход и keep-alive** + +Создать `bots/yandex-creatives/bin/login.js`: + +```js +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; + +// Разовый заход глазами: на боевом запускается внутри виртуального экрана через +// удалённый рабочий стол. Владелец вводит пароль и СМС, профиль сохраняется на диск. +const config = loadConfig(); +const { context, page } = await openBrowser(config, { headless: false }); +await page.goto(config.cabinetUrl); +console.log('Войди в кабинет в открывшемся окне. Когда войдёшь — нажми Enter здесь.'); +await new Promise((r) => process.stdin.once('data', r)); +console.log(await isLoggedIn(page, config) ? 'Вход есть.' : 'Вход НЕ виден — проверь ещё раз.'); +await context.close(); +``` + +Создать `bots/yandex-creatives/bin/keepalive.js`: + +```js +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; + +// Тихий заход, чтобы вход не заснул. Запускается по расписанию раз в ~15 минут. +const config = loadConfig(); +const { context, page } = await openBrowser(config, {}); +const ok = await isLoggedIn(page, config); +await context.close(); +console.log(JSON.stringify({ loggedIn: ok, at: new Date().toISOString() })); +process.exit(ok ? 0 : 1); +``` + +- [ ] **Step 5: Проверить, что модули грузятся** + +Run: `cd bots/yandex-creatives && node -e "import('./src/browser.js').then(()=>import('./src/session.js')).then(()=>import('./src/cabinet.js')).then(()=>console.log('ok'))"` +Expected: `ok` + +- [ ] **Step 6: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add bots/yandex-creatives/src/browser.js bots/yandex-creatives/src/session.js bots/yandex-creatives/src/cabinet.js bots/yandex-creatives/bin/login.js bots/yandex-creatives/bin/keepalive.js +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat робот креативов: браузер, проверка входа, каркас загрузки и keep-alive + +Шаг загрузки штатно бросает до заполнения разметки кабинета — заглушку не обходим. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 15: Сторож, письма и цикл работы + +**Files:** + +- Create: `bots/yandex-creatives/src/mailer.js` +- Create: `bots/yandex-creatives/src/runner.js` +- Create: `bots/yandex-creatives/bin/run.js` +- Create: `bots/yandex-creatives/README.md` +- Test: `bots/yandex-creatives/test/runner.test.js` + +- [ ] **Step 1: Написать падающий тест** + +Создать `bots/yandex-creatives/test/runner.test.js`: + +```js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runOnce } from '../src/runner.js'; + +const config = { humanDelayMs: 0 }; + +function stubs({ job = null, uploadThrows = null } = {}) { + const sent = []; + const reports = []; + return { + portal: { + takeJob: async () => job, + downloadBanner: async (b, p) => p, + reportDone: async (id) => reports.push({ ok: true, id }), + reportFailure: async (id, reason) => reports.push({ ok: false, id, reason }), + }, + browser: { + open: async () => ({ context: { close: async () => {} }, page: { screenshot: async () => {} } }), + isLoggedIn: async () => true, + uploadCreatives: async () => { if (uploadThrows) throw new Error(uploadThrows); }, + }, + mailer: { alarm: async (m) => sent.push(m), report: async (m) => sent.push(m) }, + sent, reports, + }; +} + +test('без работы ничего не делает и браузер не открывает', async () => { + const s = stubs({ job: null }); + let opened = false; + s.browser.open = async () => { opened = true; }; + + const res = await runOnce(config, s.portal, s.browser, s.mailer, { timestamp: 't' }); + + assert.equal(res.idle, true); + assert.equal(opened, false); + assert.equal(s.reports.length, 0); +}); + +test('успешная загрузка отчитывается «готово» и шлёт письмо', async () => { + const job = { id: 7, campaign_id: 42, banners: [{ banner_id: 1, width: 300, height: 250, file_url: 'u' }] }; + const s = stubs({ job }); + + const res = await runOnce(config, s.portal, s.browser, s.mailer, { timestamp: 't' }); + + assert.equal(res.ok, true); + assert.deepEqual(s.reports, [{ ok: true, id: 7 }]); +}); + +test('сбой загрузки отчитывается сбоем и шлёт алярм с причиной', async () => { + const job = { id: 7, campaign_id: 42, banners: [{ banner_id: 1, width: 300, height: 250, file_url: 'u' }] }; + const s = stubs({ job, uploadThrows: 'кнопка не найдена' }); + + const res = await runOnce(config, s.portal, s.browser, s.mailer, { timestamp: 't' }); + + assert.equal(res.ok, false); + assert.equal(s.reports[0].ok, false); + assert.match(s.reports[0].reason, /кнопка не найдена/); + assert.match(s.sent[0].reason, /кнопка не найдена/); +}); + +test('слетевший вход не пытается грузить и отчитывается сбоем', async () => { + const job = { id: 7, campaign_id: 42, banners: [] }; + const s = stubs({ job }); + s.browser.isLoggedIn = async () => false; + let uploaded = false; + s.browser.uploadCreatives = async () => { uploaded = true; }; + + const res = await runOnce(config, s.portal, s.browser, s.mailer, { timestamp: 't' }); + + assert.equal(res.ok, false); + assert.equal(uploaded, false); + assert.match(s.reports[0].reason, /вход/i); +}); +``` + +- [ ] **Step 2: Прогнать тест — должен упасть** + +Run: `cd bots/yandex-creatives && node --test` +Expected: FAIL — `runner.js` нет. + +- [ ] **Step 3: Написать письма** + +Создать `bots/yandex-creatives/src/mailer.js`: + +```js +import nodemailer from 'nodemailer'; + +export function createMailer(transport, { from, to }) { + return { + async alarm({ step, reason, campaignId, screenshotPath }) { + await transport.sendMail({ + from, to, + subject: `[Робот креативов] АЛЯРМ на шаге «${step}»`, + text: `Робот остановился и ничего не менял.\nКампания: ${campaignId}\nШаг: ${step}\nПричина: ${reason}\nКампания осталась черновиком.`, + attachments: screenshotPath ? [{ path: screenshotPath }] : [], + }); + }, + async report({ campaignId, count }) { + await transport.sendMail({ + from, to, + subject: '[Робот креативов] Готово', + text: `Креативы загружены.\nКампания: ${campaignId}\nФайлов: ${count}`, + }); + }, + }; +} + +export function smtpTransport(smtp) { + return nodemailer.createTransport({ + host: smtp.host, port: smtp.port, secure: false, + auth: { user: smtp.user, pass: smtp.pass }, + }); +} +``` + +- [ ] **Step 4: Написать цикл работы** + +Создать `bots/yandex-creatives/src/runner.js`: + +```js +import { mkdirSync, existsSync, rmSync } from 'node:fs'; + +/** + * Один проход робота: спросить работу → скачать файлы → залить в кабинет → отчитаться. + * + * Зависимости (портал, браузер, почта) передаются снаружи — так проход тестируется + * без настоящего браузера и без сети. + * + * Робот НИКОГДА не тычет вслепую: любой сбой = отчёт «сбой» + письмо со снимком экрана, + * кампания остаётся черновиком. + */ +export async function runOnce(config, portal, browser, mailer, { timestamp }) { + const job = await portal.takeJob(); + if (job === null) return { idle: true }; + + const dir = `downloads/job-${job.id}`; + const screenshot = `screenshots/job-${job.id}-${timestamp}.png`; + let context, page, step = 'start'; + + try { + mkdirSync('screenshots', { recursive: true }); + mkdirSync(dir, { recursive: true }); + + step = 'download'; + const files = []; + for (const banner of job.banners) { + const path = `${dir}/${banner.width}x${banner.height}.jpg`; + await portal.downloadBanner(banner, path); + files.push({ ...banner, path }); + } + + step = 'browser'; + ({ context, page } = await browser.open(config, {})); + + step = 'session'; + if (!(await browser.isLoggedIn(page, config))) { + throw new Error('Вход в кабинет слетел — нужен повторный вход глазами'); + } + + step = 'upload'; + await browser.uploadCreatives(page, config, files); + + await portal.reportDone(job.id); + try { await mailer.report({ campaignId: job.campaign_id, count: files.length }); } catch {} + + return { ok: true, jobId: job.id, count: files.length }; + } catch (e) { + try { await page?.screenshot({ path: screenshot, fullPage: true }); } catch {} + // Отчёт порталу — первым: даже если письмо не уйдёт, задание не зависнет в «в работе». + try { await portal.reportFailure(job.id, `${step}: ${e.message}`); } catch {} + try { + await mailer.alarm({ + step, reason: e.message, campaignId: job.campaign_id, + screenshotPath: existsSync(screenshot) ? screenshot : null, + }); + } catch {} + + return { ok: false, jobId: job.id, step, reason: e.message }; + } finally { + // Файлы клиента — временные, чистим при любом исходе (это ПДн-соседство: картинки + // клиента не должны копиться на сервере). + try { rmSync(dir, { recursive: true, force: true }); } catch {} + try { await context?.close(); } catch {} + } +} +``` + +- [ ] **Step 5: Прогнать тест — должен пройти** + +Run: `cd bots/yandex-creatives && node --test` +Expected: PASS, 12 тестов суммарно. + +- [ ] **Step 6: Написать запуск** + +Создать `bots/yandex-creatives/bin/run.js`: + +```js +import 'dotenv/config'; +import { loadConfig } from '../src/config.js'; +import { createPortal } from '../src/portal.js'; +import { openBrowser } from '../src/browser.js'; +import { isLoggedIn } from '../src/session.js'; +import { uploadCreatives } from '../src/cabinet.js'; +import { createMailer, smtpTransport } from '../src/mailer.js'; +import { runOnce } from '../src/runner.js'; + +const config = loadConfig(); +const portal = createPortal(config); +const browser = { open: openBrowser, isLoggedIn, uploadCreatives }; +const mailer = createMailer(smtpTransport(config.smtp), { from: config.alarmFrom, to: config.alarmTo }); +const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + +const res = await runOnce(config, portal, browser, mailer, { timestamp }); +console.log(JSON.stringify(res, null, 2)); +process.exit(res.idle || res.ok ? 0 : 1); +``` + +- [ ] **Step 7: Написать README** + +Создать `bots/yandex-creatives/README.md` с разделами: зачем робот, что он делает и чего +**не** делает (не создаёт кампаний, не запускает показы, не тратит деньги), как завести +вход через удалённый рабочий стол, как запускать разово и по расписанию, что делать при +письме-алярме, ссылки на спеку и находку. + +- [ ] **Step 8: Коммит по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add bots/yandex-creatives/src/mailer.js bots/yandex-creatives/src/runner.js bots/yandex-creatives/bin/run.js bots/yandex-creatives/test/runner.test.js bots/yandex-creatives/README.md +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +feat робот креативов: цикл работы, сторож и письма + +Любой сбой даёт отчёт порталу и письмо со снимком экрана, кампания остаётся +черновиком. Файлы клиента чистятся при любом исходе. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 16: Установка на боевом сервере + +**Files:** + +- Create: `docs/superpowers/runbooks/2026-07-27-creative-robot-setup.md` + +🔴 **Всё в этой задаче — действия на боевом сервере. Каждый шаг только с явного «go» владельца.** + +- [ ] **Step 1: Написать runbook, ничего не выполняя** + +Создать `docs/superpowers/runbooks/2026-07-27-creative-robot-setup.md` с точными командами: + +1. каталог `/opt/liderra-creative-robot`, владелец — отдельный системный пользователь, права `700`; +2. `npm ci` + `npx playwright install --with-deps chromium`; +3. `.env` из `.env.example`, права `600`, токен `CREATIVE_ROBOT_TOKEN` — новый случайный, + тот же вписать в `.env` портала и **перечитать конфиг под www-data** (квирк 107: + `sudo -u www-data php artisan config:cache`, НЕ от root); +4. виртуальный экран и удалённый рабочий стол, **привязанные к `127.0.0.1`** — наружу порт + не открывается, подключение через SSH-туннель; +5. разовый вход: туннель → рабочий стол → `npm run login` → пароль и СМС; +6. systemd-таймер `keepalive` раз в 15 минут и `run:once` раз в минуту (в один момент + работает одно задание — это гарантирует портал, а не робот); +7. проверка: `curl` к `/api/creative-robot/next` с токеном отдаёт `{"job":null}`; +8. откат: остановить таймеры, удалить каталог, очистить `CREATIVE_ROBOT_TOKEN` в портале. + +- [ ] **Step 2: Прогнать prod-deploy-validator** + +Вызвать агента `prod-deploy-validator` перед выполнением runbook. Ожидание: вердикт GO. +При NO-GO — исправить причину, не обходить. + +- [ ] **Step 3: Выполнить runbook по «go» владельца** + +Выполнять по одному пункту, показывая владельцу результат каждого. На любом непонятном +ответе сервера — остановиться и спросить. + +- [ ] **Step 4: Перезапустить политики служебных ролей** + +```bash +# 🔴 Без этого робот и джобы молча увидят ноль строк в новых таблицах. +psql "$PROD_DSN" -f db/03_service_bypass_policies.sql +``` + +Проверка: под ролью `crm_supplier_worker` `SELECT count(*) FROM ad_creative_jobs;` не падает +и не возвращает ноль там, где строки есть. + +- [ ] **Step 5: Коммит runbook по «go»** + +```bash +LEFTHOOK_EXCLUDE=larastan git add docs/superpowers/runbooks/2026-07-27-creative-robot-setup.md +LEFTHOOK_EXCLUDE=larastan git commit -F - <<'EOF' +docs робот креативов: runbook установки на боевом сервере + +Экран и удалённый рабочий стол слушают только localhost, наружу порт не открывается. + +Co-Authored-By: Claude Opus 5 +EOF +``` + +--- + +## Task 17: Живая сверка и закрытие + +**Files:** + +- Create: `docs/superpowers/2026-07-27-HANDOFF-creative-robot-STATE.md` + +🔴 **Только по отдельному «go» владельца. Реальная кампания в боевом кабинете.** + +- [ ] **Step 1: Прогнать весь рекламный модуль и фронт** + +Run: `cd app && php artisan test --filter=Advertising && npm run test:vue` +Expected: PASS. Плюс `composer stan` на обычном чекауте — в worktree Larastan не работает. + +- [ ] **Step 2: Запросить code review** + +Вызвать `superpowers:requesting-code-review` на всю ветку. Замечания закрыть до сверки. + +- [ ] **Step 3: Живая сверка на минимальном наборе** + +С явного «go»: одна тестовая кампания, 2–3 баннера, рубильник `YANDEX_DIRECT_ENABLED` +включается только на время проверки. Проверить: задание встало → робот забрал → файлы +появились в кабинете → портал проставил номера → запуск создал объявления → модерация +поехала. После — вернуть рубильник в безопасное положение. + +- [ ] **Step 4: Закрыть «честно неизвестное» из спеки §10** + +Ответить фактами на четыре вопроса: мусор при перезаливке, лимиты Яндекса, срок жизни +входа, точные экраны. Записать ответы в findings. + +- [ ] **Step 5: Написать хэндофф состояния** + +Создать `docs/superpowers/2026-07-27-HANDOFF-creative-robot-STATE.md`: что сделано, что +на бою, где рубильники, как откатить, что осталось. + +- [ ] **Step 6: Коммит по «go»** + +--- + +## Самопроверка плана + +**Покрытие спеки:** + +| Раздел спеки | Задачи | +|---|---| +| §3 схема, слепок «до/после», один файл на размер | 3, 4, 8 — плюс существующий `updateOrCreate` | +| §3 превью через `PreviewUrl` | уже есть `getCreativePreview`, отдельной задачи не нужно | +| §4 части робота, ресурсы, вход через рабочий стол | 12, 13, 14, 15, 16 | +| §4 точные экраны — снять руками | 11 | +| §5 сбои, письмо со снимком, кампания остаётся черновиком | 15 | +| §6 номера у баннера, N объявлений, частичная модерация | 1, 5, 6 | +| §6 очередь, два адреса, токен, проверка файлов | 7, 9, 10 — проверка файлов уже есть, кап поднят в 2 | +| §6 грабля `03_service_bypass_policies.sql` | 1, 7 (CHANGELOG), 16 шаг 4 | +| §7 робот без пути к деньгам | 9 — в канале нет действий запуска | +| §7 файлы клиента временные | 15 — `rmSync` в `finally` | +| §8 тестирование | тесты в каждой задаче + 17 | +| §10 честно неизвестное | 17 шаг 4 | +| §11 критерии готовности | 16, 17 | + +**Заглушки:** «не размечено» в `cabinet.js` — намеренная, штатно бросает, снимается в Task 14 +после Task 11. Все прочие шаги содержат готовый код. + +**Согласованность имён:** `listImageCreativeIds()` — Task 3, используется в 8. `CreativeIdMatcher::match()` +— Task 4, используется в 8. `CreativeJobService::enqueue/takeNext/complete/fail` — Task 8, +используется в 9 и 10. `AdCampaignBanner::MOD_*` — Task 1, используется в 5, 6. `runOnce()` — +Task 15, используется в `bin/run.js`. Заголовок токена `X-Creative-Robot-Token` — одинаков +в Task 9 и 13. + +**Известное расхождение, снимается по ходу:** `Http::assertSentCount(7)` в Task 5 шаг 1 — +предварительное число, уточняется в шаге 2 по фактическому выводу. 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..0786d521 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md @@ -0,0 +1,2173 @@ +# Отказы модерации Яндекса — окно передачи. План работ + +> **Для исполнителя:** ОБЯЗАТЕЛЬНЫЙ СУБ-СКИЛ — `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: 🔴 снять живую разметку кабинета — ДО кода — ✅ ЗАКРЫТА 28.07.2026 + +**Это не программирование. Это наблюдение, и без него заход 4 не начинается.** + +- [x] **Шаг 1: договориться с владельцем о заходе в кабинет** + +Вход в живой рекламный кабинет Яндекса — **только вместе с владельцем**. + +- [x] **Шаг 2: вызвать отказ нарочно** + +Залить заведомо непроходное объявление и дождаться вердикта модерации. Ждать живого +клиентского отказа не нужно. + +Сделано: пустышка № 713110757, объявление № 17787055204, тематика «займ под залог». +Вердикт пришёл на вторые сутки: **«Модератор отклонил объявление · Нет предупреждения: +финансовые услуги»**. + +- [x] **Шаг 3: записать увиденное** + +Раздел `bots/yandex-creatives/docs/cabinet-flow.md` **§7** написан по натуре, снимок — +`docs/superpowers/findings/2026-07-28-yandex-otkaz-moderacii/okno-otkaza-razvernuto.png`. + +Что снято: причина живёт **только в списке объявлений**, в окне `BannerStatusCell.Popup`, +которое открывается **кликом** (не наведением) по `AggregatedStatusContent`; краткая +причина — `RejectRow`, подробное пояснение — `MiniGridRow` и появляется **только после +раскрытия строки**. + +🔴 **Два ответа оказались отрицательными, и это важнее найденного:** + +- **кнопки повторной модерации нет** — Яндекс отправляет на перепроверку сам, по факту + сохранения правки; +- **прикладывать документ в кабинете некуда** — в окне отказа ноль полей для файла, + документы уходят наружу (чат Яндекса / форма `forms.yandex.ru`). Подробности и оговорка + про лицензируемые тематики — `cabinet-flow.md` §7.5. + +- [ ] **Шаг 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: разведка — робот читает кабинет — ✅ ЗАКРЫТА 28.07.2026 + +**Файлы:** + +- Изменить: `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` + +- [x] Отказ по программному интерфейсу ставит задание разведки **ровно один раз**: + повторный обход второго задания не создаёт. 🔴 Ключ дедупа — **номер объявления**, + а не кампания: отказ никуда не девается, и дедуп «по кампании среди незавершённых» + после закрытия первой разведки поставил бы вторую, и так по кругу. Журнал схемы **v9.16** +- [x] Робот с заданием разведки **ничего не меняет** в кабинете: сторож проверяет и + «Сохранить», и «Запустить кампанию», и что робот вообще не заходит на форму объявления +- [x] Доклад разведки попадает в ленту от имени `yandex` вместе со снимком экрана — + отдельная ручка `POST /api/creative-robot/jobs/{id}/inspection`, форма со снимком +- [x] Робот не понял, что видит — задание сбойное, письмо владельцу, **в ленту клиенту + ничего не сочиняем** +- [x] В админке список «ждёт разбора» — `GET /api/admin/advertising/robot-stuck`. + Все сбойные задания робота, а не только разведка: сорванная заливка — тоже вставшая + кампания. ⚠️ Ручка есть, ЭКРАНА нет — рисовать отдельно +- [x] Рубильник Директа выключен — задание не ставится и роботу не выдаётся. 🔴 Проверка + поднята в саму выдачу: раньше она сидела внутри построения клиента Директа, то есть + срабатывала только там, где мы ходим за слепком, а разведке слепок не нужен + +--- + +## Задача 16: доставка документа — ✅ ЗАКРЫТА ПО ФАКТУ 28.07.2026, дороги нет + +> 🔴 **Вторая проба дала ответ: поля для документа нет и у лицензируемой тематики.** +> Объявление № 17787102785 («Стоматология») отклонено — «Нет предупреждений: медицина», +> окно отказа совпало с первым до метки, список полей пуст. Обоим отказам лечение одно — +> **переделать картинку**, документ не помог бы ни в одном случае. +> +> **Решение владельца 28.07.2026: закрыть задачу и сказать клиенту правду.** Что сделано +> вместо доставки роботом: +> +> - [x] приём документа в переписке **остаётся** — файл клиента сохраняется как раньше; +> - [x] сразу после приёма в ленту ложится служебная отметка: документ у нас, передать +> его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь; +> - [x] владельцу уходит письмо `AdDocumentAttachedMail` на адрес +> `services.monitoring.alert_email` — иначе «отнесём сами» было бы пустым обещанием, +> а файл просто лёг бы на диск; +> - [x] отметка и письмо под `try/catch`: беда с почтой не возвращает клиенту отказ +> на успешно принятый документ. +> +> Ниже — прежний текст задачи, оставлен для истории. + +> 🔴🔴 **Задача 13 показала, что везти документ роботом НЕКУДА.** В окне отказа Яндекса +> нет ни одного поля для файла (проверено программно, список полей пуст), а кнопки +> «Написать в чат» и «Написать письмо» уводят **наружу** из кабинета — в чат поддержки +> и форму `forms.yandex.ru`. Кнопки «отправить на повторную модерацию» тоже нет: +> перепроверка запускается сама, по факту сохранения правки. +> +> Оговорка: наш отказ был «поправьте креатив», а не «пришлите лицензию». Возможно, у +> лицензируемых тематик окно отказа другое. Проверяется только вторым нарочным отказом +> (мед / алкоголь / банк), это ещё сутки-двое ожидания. +> +> **Пока это не проверено — задачу не начинать.** Разбор путей и решение — за владельцем, +> см. `cabinet-flow.md` §7.5. + +**Файлы:** те же, что в задаче 15, плюс `bots/yandex-creatives/src/portal.js` — скачивание +документа по адресу портала. + +Точный код — после задачи 13. Зафиксированные требования: + +- [ ] Документ клиента доезжает до кабинета и уходит на повторную модерацию +- [ ] Робот берёт файл **только с адреса портала** — существующая проверка + `assertPortalUrl` распространяется и на документы +- [ ] После доставки в ленту ложится служебная отметка «документ отправлен в Яндекс», + письма клиенту при этом нет +- [ ] Яндекс уже принял рекламу, а документ пришёл — файл сохраняем, в ленту пишем + «уже принято, документ не понадобился», робота не гоняем +- [x] ✅ **Защита документа от чужого клиента — СДЕЛАНА 29.07.2026, до этой задачи.** + Оба рубежа стоят и проверены вырезанием по отдельности: + составные ключи базы (журнал схемы **v9.13** и **v9.15**) и постановка доставки + `CreativeJobService::enqueueDelivery()`, которая берёт сообщение только связью + от кампании. 🔴 **Обязанность этой задачи — звать `enqueueDelivery()`, а не собирать + задание руками.** Сырой номер сообщения в выборку попадать не должен нигде. +- [x] ✅ **Догоняющий грант** `GRANT SELECT ON ad_campaign_messages TO crm_admin_user` — + выдан записью **v9.14**. +- [ ] 🪤 **Служебная отметка в ленту от имени робота потребует ещё прав.** Роль + `crm_admin_user` имеет на `ad_campaign_messages` только SELECT — вызов + `CampaignMessageService::postSystem()` на соединении `pgsql_admin` упадёт на + «permission denied» и на нехватке прав к нумератору. Либо писать отметку порталом, + либо догоняющей миграцией выдать INSERT и `USAGE` на `ad_campaign_messages_id_seq`. + +--- + +## 🛑 Конец захода 4 — кусок 3 закрыт + +- [ ] Полный прогон обоих наборов +- [ ] Приёмочный лист замысла §8, раздел «Кусок 3» и «Общее» — отметить каждую строку +- [ ] Обновить PROGRESS и STATE-файл +- [ ] Коммит по эскейпу владельца + +--- + +## Перед выкатом на боевой + +- [ ] `prod-deploy-validator` → GO +- [ ] 🔴 После миграций перезапустить `db/03_service_bypass_policies.sql` — в ветке новые + RLS-таблицы, иначе служебные роли увидят ноль **молча**, а журнал будет зелёный +- [ ] Проверить, что мест снятия заморозки денег по-прежнему четыре +- [ ] Выкат — только с явного «go» владельца diff --git a/docs/superpowers/specs/2026-07-25-yandex-reklama-ux-audit-fixes-design.md b/docs/superpowers/specs/2026-07-25-yandex-reklama-ux-audit-fixes-design.md new file mode 100644 index 00000000..1368dc9a --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-yandex-reklama-ux-audit-fixes-design.md @@ -0,0 +1,130 @@ +# Дизайн: системные UX-правки Яндекс-блока рекламы (по аудиту Ф0) + +**Дата:** 2026-07-25 +**Статус:** согласовано владельцем (Ф1). Следующий шаг — Ф2 (writing-plans). +**Вход:** аудит Ф0 — `docs/superpowers/findings/2026-07-25-reklama-modul-zamechaniya-zhivoy-test.md` (5 замечаний владельца + находки F0-9…F0-24, скриншоты `…/2026-07-25-reklama-modul-audit/`). +**Стек:** Vue 3 + Vuetify 3 (PSR_v1 R6 фильтр, Forest-override). Backend PHP 8.3 / Laravel 13. Деньги — `bcmath` + `AdMarkup`/`AdWalletService`/`LedgerService`. + +--- + +## 1. Цель и рамки + +Устранить UX-дыры во **всём клиентском Яндекс-блоке** (витрина → кошелёк → мастер 1–4 → список/карточка) + доступность и мобильный. Админ-экран «Расход и маржа» — отдельно, под админ-доступом (по коду структура ок, живьём не пройден). + +**Вне рамок:** боевой Директ (выключен, ждём одобрение Яндекса), реальный go-live онлайн-оплаты ЮKassa (ждёт тест-платёж владельца) — эти две внешние зависимости не «чинятся» здесь, но карта оплаты кошелька от go-live ЮKassa **зависит** (см. §4.A). + +**Источник лимитов Яндекса — канон `app/app/Services/Advertising/CreativeValidator.php`, не выдумывать:** +- Заголовок ≤ **56** символов, любое слово ≤ **22**. +- Второй заголовок ≤ **30** обычных + до **15** «узких» (`! , . ; : "`). +- Текст ≤ **81** обычных + до **15** узких, любое слово ≤ **23**. +- Картинка: сторона ≥ **450×450**, вес ≤ **10 МБ** (`10*1024*1024`), MIME `image/jpeg|png|gif`. Пропорции сервер не проверяет (`TODO(verify)`). + +--- + +## 2. Проверенная денежная корректность (менять не нужно) + +`CampaignLauncher::launch` уже конвертирует и бюджет, и ставку клика в «яндексовые» рубли перед отправкой в Директ: +- недельный лимит: `$markup->yandexFromClient(weekly_budget_rub)` → `WeeklySpendLimit` (×1e6); +- ставка клика: `$markup->yandexFromClient(click_bid_rub ?? '10.00')` → `ContextBid` (×1e6); +- заморозка в кошельке — в **клиентских** рублях (`AdWalletService::freeze`). + +⇒ Лимит клиента в клиентских рублях соблюдается (Яндекс тратит ≈ лимит ÷ 1,3; клиент платит это ×1,3 = его лимит). **Требование владельца «проверь, что лимит тоже снижается на 30%» выполнено — в коде верно.** Новое поле «цена за клик» ложится в уже существующий `click_bid_rub` и ту же конвертацию. + +--- + +## 3. Решения владельца (Ф1) + +| Тема | Решение | +|---|---| +| Кошелёк в меню | **A** — пункт-ссылка «Рекламный кошелёк» → отдельная страница (число в меню не показываем). | +| Цена за клик | **B** — клиент задаёт максимальную цену (в клиентских ₽); в Яндекс уходит ÷1,3. | +| «Мой список» | Делаем **загрузку сейчас** (файл + вставка номеров) + согласие ПДн. | +| Оплата картой | **Проверить/починить** (зависит от go-live ЮKassa). | +| Черновики | **Переиспользовать** последний незапущенный + кнопка **«Удалить»** (нужен эндпоинт удаления). | +| «Бюджет в день» | **Убрать** поле. | + +--- + +## 4. Дизайн по областям + +### 4.A. Рекламный кошелёк +- **Пункт меню «Рекламный кошелёк»** (`advertisingChannels.ts` + `AppSidebar.vue`) → новый роут/страница кошелька: баланс (Кошелёк / Заморожено / Свободно **с подсказками-пояснениями**), «Пополнить», **история пополнений и списаний** (лента транзакций кошелька). Компактный баланс над мастером на `/advertising/yandex` — оставляем. +- **Диалог «Пополнить» (`AdWalletTopupDialog`) переделать:** пресеты сумм (1000 / 3000 / 5000 / своя), почин тесной вёрстки (кнопки в столбик на узком, «Закрыть» не обрезается), **выключенная кнопка выглядит выключенной** (не сплошная зелёная при пустой сумме). Валидацию «минимум 100 ₽» оставить. +- **Карта:** проверить путь «Оплатить картой» для кошелька (какой эндпоинт, идёт ли через ЮKassa). Если go-live ЮKassa не завершён и карта на бою не работает — **честно зафиксировать внешнюю блокировку**, решить с владельцем (временно спрятать/ждать). Не выдаём «готово», если happy-path картой в бою не проверен. + +### 4.B. Мастер, шаг 1 «Кого рекламируем» (`CampaignWizard.vue`) +- Текст шага: → «Покажем рекламу людям, которых мы нашли по вашим источникам — выберите, за сколько дней брать контакты.» +- Подпись ползунка: «за сколько дней брать свежие **сделки**» → «…свежие **контакты**». +- **«Мой список» (загрузка):** тумблер `use_uploaded_list` включает блок загрузки: + - вход двумя способами — **файл** (Excel/CSV) **или** вставка номеров текстом; + - парс/нормализация номеров (формат `79…`, как в существующем finder-коде — переиспользовать, не дублировать), дедуп, показ «распознано N номеров / отброшено M»; + - запись в `ad_campaign_phones` (таблица есть) через новый бэкенд-эндпоинт загрузки телефонов кампании (в `routes/web.php` группе `/api/advertising` его нет — добавить) + функция в `api/advertising.ts`; + - **ПДн/согласие:** галочка согласия на шаге 4 (`needsConsent` при `use_uploaded_list`) уже есть — оставить; чужие номера = ПДн, в тесты реальные номера НЕ класть (gitleaks не смотрит в `tests/`, см. память). + +### 4.C. Мастер, шаг 2 «Объявления» (`CreativeForm.vue`) +- **Живые клиентские проверки (до отправки), лимиты — из `CreativeValidator` (не хардкодить наугад, держать один источник):** + - счётчики символов заголовок/второй/текст + предупреждение при превышении (Vuetify `counter`/`:rules`); учёт «узких» символов по правилу сервера; + - проверка ссылки — корректный URL (http/https), подсказка формата; + - картинка — при выборе читать `naturalWidth/Height` (через `Image`/`createObjectURL`) и `file.size`, MIME; понятная ошибка (мин. 450×450, ≤ 10 МБ, JPG/PNG/GIF) до отправки. +- **Дружелюбные ошибки:** маппинг серверных 422 на человеческие подписи полей (не «поле title/text/href»). +- «Текст объявления» — **пометить обязательным** (звёздочка/`required`). +- **Баг «2 картинки» (F0/4c):** воспроизвести и починить на Ф3 через `superpowers:debugging`. Гипотезы: квирк версии `v-file-input` (`File[]` vs `File`) либо связка `image_normal`/`image_wide`. На чистой сессии Ф0 двоение на уровне поля не воспроизведено (`input.files.length=1`) — искать в связке/версии. +- **Объявление не теряется (F0-10):** при «Далее» с шага 2, если форма креатива заполнена и не добавлена — авто-добавлять (или явно требовать «Добавить объявление» с понятным сообщением). Дефолт — **авто-добавление валидной формы** (щадящий путь). +- Значок чата 💬 не должен перекрывать «Далее» (z-index/отступ на шаге мастера). + +### 4.D. Мастер, шаг 3 «Бюджет» +- **Новое поле «Цена за клик»** (клиентские ₽) → `click_bid_rub`. Подпись «Сколько максимум готов платить за переход», дефолт/подсказка (дефолт `10.00`, как в лаунчере — не выдумывать иные значения). Конвертация в Яндекс ÷1,3 уже есть. +- **Убрать «Бюджет в день»** (`daily_budget_rub`) из мастера — Яндекс при ручной стратегии его игнорирует. (Колонку в БД не трогаем; просто не показываем/не шлём.) +- Недельный бюджет — убрать некрасивое «.00» в отображении. + +### 4.E. Мастер, шаг 4 «Проверка и запуск» +- В сводку добавить **цену за клик** и **сайт/ссылку** объявления. Галочка-согласие для «моего списка» — как есть. + +### 4.F. Список кампаний / карточка (`CampaignList.vue`, `AdvertisingYandexView.vue`) +- **Черновики:** при входе в мастер — **переиспользовать** последний незапущенный черновик тенанта вместо `createCampaign` каждый раз; кнопка **«Удалить»** на карточке черновика (новый эндпоинт удаления + `api/advertising.ts`; удалять только `draft`). +- **Имя черновика различимое:** дата + № (или заголовок первого объявления), чтобы не было 4× «Реклама от 25.07.2026». +- **«Отчёт» на черновике** — прятать/дизейблить (отчитываться нечего). + +### 4.G. Доступность и мобильный +- Вкладки «Мои кампании / Новая реклама» — задать доступное имя (`aria-label`/корректный slot), чтобы скринридер их озвучивал. +- Мобильный (390px): нижняя навигация и чат не перекрывают карточки; кнопка «Пополнить рекламный кошелёк» не вылезает по ширине. +- Формальный **Pa11y** по авторизованным рекламным экранам — локальным авторизованным прогоном на **Ф4** (текущий `pa11y.config.json` бьёт по `localhost:8000` и публичным страницам). + +### 4.H. Админка «Расход и маржа» (`AdminAdvertisingView.vue`) +- Пройти живьём под админ-доступом (позже). Предварительно к проверке: суффикс «₽» у чисел (`formatPlain`), возможный экспорт таблицы, зависимость видимости данных от `srv_bypass` для `crm_admin_user` на кластере (иначе тихий ноль — см. память выката). + +--- + +## 5. Тестирование (TDD на логику — Ф3) + +Тесты **до кода** на чистую логику: +- URL-валидатор (http/https, мусор типа «это_не_ссылка» → невалидно). +- Счётчики символов заголовка/второго/текста с учётом «узких» символов и длинных слов (граничные 56/57, 30+15, 81+15, слово 22/23) — сверять с `CreativeValidator` (единый источник; вынести общие пределы, чтобы фронт и бэк не разошлись). +- Чтение размеров/веса картинки (≥450×450, ≤10 МБ, MIME). +- Парсер «моего списка» (нормализация `79…`, дедуп, счёт распознанных/отброшенных) — БЕЗ реальных ПДн в фикстурах. +- Баг «2 картинки» — сначала падающий тест, воспроизводящий двоение, потом фикс (`superpowers:debugging`). +Визуальная сборка (шаблоны/токены) — look-first, вне TDD (PSR R3). + +--- + +## 6. Порядок (приоритеты) + +1. **Быстрое/заметное:** текст шага 1 + подпись ползунка; убрать «Бюджет в день»; спрятать «Отчёт» на черновике; доступное имя вкладок; чат не перекрывает «Далее». +2. **Проверки формы + баг картинки + цена за клик:** живые проверки (ссылка/счётчики/картинка), дружелюбные ошибки, «Текст» обязателен, фикс «2 картинки», поле «Цена за клик» + сводка. +3. **Крупное:** загрузка «моего списка» (бэкенд+фронт+ПДн); переиспользование/удаление черновиков; страница кошелька + переделка окна «Пополнить»; проверка/починка карты (внешняя зависимость ЮKassa). + +Каждый пункт — с ревью между (PSR R5: логика — Superpowers code-review; UI — Frontend Design; a11y — Pa11y). + +--- + +## 7. Внешние зависимости и честные ограничения +- **Карта оплаты кошелька** зависит от go-live ЮKassa (не завершён). Happy-path картой в бою не проверялся — «готово» по карте не заявляем без живой проверки. +- **Боевой Директ выключен** → активные/на-паузе кампании и живой отчёт с показами недостижимы; проверка этих состояний — после включения Директа. +- **Админ-экран** — нужен админ-доступ (nginx basic-auth + логин). +- Новые RLS-таблицы (если появятся под «мой список»/удаление) — не забыть `db/03_service_bypass_policies.sql` пере-применить (грабля srv_bypass, память выката). + +--- + +## 8. Затрагиваемые файлы (ориентир, точный список — в плане Ф2) +- Фронт: `components/advertising/{AdWalletHeader,AdWalletTopupDialog,CampaignWizard,CreativeForm,CampaignList,CampaignReportDialog}.vue`, новый экран страницы кошелька, `views/advertising/AdvertisingYandexView.vue`, `components/layout/{AppSidebar.vue,advertisingChannels.ts}`, `api/advertising.ts`, общий модуль лимитов креатива. +- Бэкенд: `routes/web.php` (`/api/advertising` — эндпоинты загрузки телефонов и удаления черновика), `Http/Controllers/Api/AdvertisingCampaignController.php`, сервисы загрузки/парсинга телефонов, при необходимости — общий источник лимитов (сверка с `CreativeValidator`). diff --git a/docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md b/docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md new file mode 100644 index 00000000..5ab35372 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-yandex-creative-robot-design.md @@ -0,0 +1,257 @@ +# Дизайн: робот-грузчик креативов в кабинет Яндекс Директа + переход ядра на набор креативов + +**Дата:** 2026-07-27 +**Статус:** дизайн согласован с владельцем (brainstorming), готов к плану реализации +**Заменяет:** пункт «РОБОТ (Часть C) — робот-креативщик» из дорожной карты рекламы «за показы» +**Основание:** [findings 2026-07-27 «Конструктор креативов Яндекса закрыт»](../findings/2026-07-27-yandex-konstruktor-kreativov-zakryt.md) +**Образец архитектуры:** [спека МТС-бота от 26.07](2026-07-26-mts-telegram-ads-bot-design.md) + +--- + +## 1. Зачем это и что изменилось + +Портал умеет заводить медийную кампанию «за показы» в Директе через API целиком — кроме одного +шага: **картиночный креатив через API создать нельзя**. `creatives.add` принимает только +`VideoExtensionCreative`; картинки заводятся исключительно в веб-кабинете. + +До 27.07 мы считали, что этот шаг — «сделать в Конструкторе Яндекса один адаптивный креатив из одной +картинки, который покроет все размеры». **Живая проверка кабинета это опровергла:** конструктор +закрыт 01.06.2026, показы его креативов остановлены с 30.06, а медийная кампания устроена как +**объявление на каждый размер блока** со своим креативом. Единственный доступный путь — «Загрузить +креативы» готовыми файлами. + +Поэтому работа делится на две связанные части, и обе входят в этот спек: + +- **Робот** — относит готовые файлы клиента в кабинет. +- **Ядро** — переходит с «один креатив на кампанию» на «креатив у каждого баннера». + +Одно без другого бессмысленно: роботу некуда отдать номера, а ядро без робота требует ручного +оформления каждой кампании. + +--- + +## 2. Решения владельца (27.07.2026) + +| Вопрос | Решение | +|---|---| +| Границы робота | Робот **только грузит файлы** и говорит «готово». Кампанию, группу, аудиторию и объявления делает портал через API | +| Как робот получает работу | **Сам опрашивает портал** и сам сообщает результат. Клиент нажал «Запустить» — дальше без человека | +| Кто готовит файлы | **Только клиент грузит готовые.** Портал ничего не режет | +| Полный набор размеров | **15 размеров** (`BannerSizes`) — целевой набор; клиент грузит сколько есть | +| Где живёт робот | **На боевом сервере**, рядом с Лидеррой | +| Вход в кабинет | **Экран на боевом + удалённый рабочий стол** (слушает только localhost, доступ через SSH-туннель — наружу порт не открывается) | +| Объём работы | Робот и переделка ядра — **одной спекой** | + +### Осознанные последствия (владельцу озвучены, решение принято) + +- **Клиент обязан принести файлы точных размеров Яндекса весом ≤ 512 КБ.** У кого нет дизайнера — + будет трудно. Портал берёт на себя понятную человеческую проверку и объяснение, что не так. +- **Готовый резчик `CampaignBannerService` / `BannerGenerator` остаётся неиспользованным.** Не + удаляем в этот заход — это отдельное решение. `BannerSizes` продолжает работать как канонический + список допустимых размеров для проверки загрузок. +- **Живой вход в рекламный кабинет с деньгами лежит на боевом сервере.** Доступ к серверу = доступ + в кабинет без СМС. Папку профиля закрываем правами. Тот же риск был принят для МТС-бота. + +--- + +## 3. Общая схема + +``` +КЛИЕНТ ПОРТАЛ РОБОТ ЯНДЕКС + │ │ │ │ + ├─ грузит файлы ─────►│ │ │ + │ ├─ проверяет размер/вес │ │ + │ ├─ ставит задание в очередь │ │ + │ │◄──── «есть работа?» ──────┤ │ + │ ├──── задание + файлы ─────►│ │ + │ ├─ слепок креативов «до» ───────────────────────► │ + │ │ ├─ грузит файлы ────► │ + │ │◄──── «готово» ────────────┤ │ + │ ├─ слепок креативов «после» ────────────────────► │ + │ ├─ новые номера ↔ баннеры по Width/Height │ + │ ├─ кампания + группа + аудитория + N объявлений ► │ + │◄─ «на модерации» ───┤ │ │ +``` + +Робот делает **ровно одно действие** — загрузку файлов. Не читает номера, не создаёт кампаний, +не запускает показы, к деньгам не прикасается. + +### Как портал узнаёт номера креативов + +Не по имени: Яндекс сам называет все загруженные креативы «Новый креатив» (проверено). Работает +**слепок «до/после»**: + +1. Перед выдачей задания роботу портал зовёт `creatives.get` и запоминает множество `Id`. +2. Робот грузит файлы. +3. После «готово» портал зовёт `creatives.get` снова. Разница множеств = наши новые креативы. +4. Каждый новый креатив сопоставляется со своим баннером по паре `Width`/`Height`. + +**Условия корректности (обязательны):** + +- в наборе кампании **не более одного файла на размер** — иначе сопоставление неоднозначно; +- робот обрабатывает **строго одно задание за раз** — иначе слепки перемешаются; +- отбор ведётся по типу `HTML5_CREATIVE` (именно так Яндекс хранит загруженные картинки). + +Если разница множеств не совпала с ожидаемым набором размеров — задание помечается «требует +внимания», кампания остаётся черновиком, идёт письмо-алярм. Наугад ничего не привязываем. + +### Превью для клиента + +`creatives.get` отдаёт `PreviewUrl` — прямую ссылку на превью у Яндекса. Используем её для показа +клиенту; своё хранилище превью не нужно. + +--- + +## 4. Робот: устройство + +Отдельная программа на **Playwright (Node)**, живёт на боевом сервере, изолирована от портала: +её поломка не задевает `liderra.ru`. Скелет повторяет обкатанный МТС-бот (`bots/mts-telegram-ads`). + +| Компонент | Что делает | От чего зависит | +|---|---|---| +| **Хранитель входа** | Держит персистентный профиль браузера с живым входом в кабинет. Раз в ~15 мин тихо заходит, чтобы вход не заснул. Перед каждым заданием проверяет живость входа | Профиль на диске | +| **Грузчик** | Берёт задание, скачивает файлы из портала, открывает кабинет, грузит через «Загрузить креативы», дожидается подтверждения, сообщает «готово» | Живой вход, задание | +| **Сторож** | Ловит сбой, делает снимок экрана, шлёт письмо, аккуратно останавливается | Почта | +| **Отчётчик** | Журнал + письмо «готово» при успехе | — | + +**Ресурсы боевого сервера проверены:** 12 ядер, 36 ГБ памяти (занято 2), 66 ГБ свободного диска, +нагрузка 0.00, Node 20 уже стоит. Браузер ставится дополнительно. Портал этого не почувствует. + +### Вход в кабинет на сервере без экрана + +Ставим виртуальный экран и удалённый рабочий стол, **привязанные к `127.0.0.1`**. Владелец +подключается через SSH-туннель и логинится в кабинет глазами (пароль + СМС) один раз. Наружу новый +порт не открывается. Дальше вход держит хранитель — по опыту МТС-бота это недели. + +### Точная последовательность экранов + +В спеке намеренно **не фиксируется**. Робот «выучит» реальный поток на фазе реализации: оператор +один раз пройдёт загрузку руками, запишет фактические экраны и кнопки, и под них настроятся +селекторы. Известная опора — кнопка меню креатива `ImageCreativeEditor.OpenMenu` и пункт +«Загрузить креативы». + +--- + +## 5. Обработка сбоев + +Робот **никогда не тычет вслепую**. На любом из сбоев: снимок экрана → письмо → задание +«требует внимания» → **кампания остаётся черновиком и не запускается**. + +| Сбой | Что значит | +|---|---| +| Вход слетел / кабинет просит СМС | Нужен человек: зайти по удалённому рабочему столу и войти заново | +| Капча | То же | +| Не найдена ожидаемая кнопка/поле | Яндекс поменял вёрстку — нужна правка селекторов | +| Яндекс отверг файл | Размер/вес не подошли: сообщение показываем клиенту по-человечески | +| Кабинет вернул ошибку | Разбор по снимку экрана | +| Слепок «до/после» не сошёлся | Загрузилось не то количество или чужие креативы — привязку не делаем | + +Письмо содержит: тип сбоя, шаг, номер кампании и **снимок экрана**. Канал — почта, как у МТС-бота. + +**Отдельный «черновой режим» не нужен:** робот физически не может ничего запустить, загрузка +картинок бесплатна, а запуск кампании остаётся за порталом и уже закрыт рубильником +`YANDEX_DIRECT_ENABLED`. + +--- + +## 6. Что меняется в ядре (Часть 4) + +Ломать нечего: рубильник Директа выключен, через портал на бою не создано ни одной кампании. + +| Сейчас | Становится | +|---|---| +| `ad_campaigns.yandex_creative_id` — один номер на кампанию | Номер **у каждого баннера** | +| `CampaignLauncher` создаёт **одно** `CpmBannerAdBuilderAd` | Создаёт объявление **на каждый баннер набора** | +| Проверка перед запуском «номер креатива задан» | «у всех баннеров набора есть номера» | +| Модерация — одна на кампанию | По каждому объявлению; часть может пройти, часть быть отклонена | + +**Правило по частичной модерации (важно для денег):** кампания считается работающей, если принято +**хотя бы одно** объявление — отклонённые просто не показываются, клиенту показываем какие и почему. +Кампания уходит в «отклонено» и заморозка снимается **только если отклонены все** объявления. Это +сохраняет существующее поведение снятия заморозки на четырёх выходах кампании и не создаёт пятого. +| Оператор вписывает номер руками в админке | Делает робот; ручной ввод остаётся **аварийным** путём | + +**Куда класть номера.** Готовое место есть — таблица `ad_campaign_banners` (уже хранит +`tenant_id`, `campaign_id`, `width`, `height`, `path`, `bytes`, `included`). Ей нужны новые столбцы: +номер креатива в Яндексе, номер объявления в Яндексе, состояние модерации. Новой сущности не надо. + +**Что добавляется:** + +- очередь заданий робота в базе портала (кампания, состояние, попытки, причина сбоя); +- два служебных адреса для робота — «дай работу» и «принял, готово» — со **своим ключом**, + отдельным от клиентских токенов; +- проверка загружаемых клиентом файлов: точный размер из `BannerSizes`, вес ≤ 512 КБ, + JPG/PNG/GIF, **не более одного файла на размер**, **минимум один файл в наборе**; + сообщения — по-русски, для не-технаря; +- журнал и письмо-алярм робота. + +**🔴 Грабля, которую нельзя забыть.** У новых таблиц на боевом кластере служебные роли +(`crm_supplier_worker`, `crm_admin_user`) **не BYPASSRLS** — кросс-тенантный доступ им даёт +`db/03_service_bypass_policies.sql`. После добавления таблиц его надо **перезапустить**, иначе +робот и джобы будут молча видеть ноль (тихий ноль, зелёные тесты). Плюс обязательный прогон +`rls-reviewer` и запись в `db/CHANGELOG_schema.md`. + +--- + +## 7. Безопасность и границы + +- **Робот не имеет пути к деньгам.** В его коде нет создания и запуска кампаний — только загрузка + файлов. Это проверяемое свойство, а не обещание. +- **Файлы клиентов** — временные: скачал, загрузил, удалил. Не логируем содержимое. +- **Ключ робота** — отдельный от клиентских, только на два служебных адреса. +- **Живой вход в кабинет на боевом сервере** — принятый риск (см. §2). Профиль браузера закрыт + правами доступа. +- **«Серая» автоматизация.** Как и с МТС: работа роботом в веб-кабинете против правил. Смягчение — + человекоподобный темп с паузами, редкие заходы, работа через живую сессию владельца. Риск + ограничения кабинета осознан. + +--- + +## 8. Тестирование + +- **Робот** — на тестовой кампании с маленьким набором файлов; проверяем весь путь до «готово» + и что слепок сошёлся. Денег не тратит по построению. +- **Ядро** — `Http::fake`-тесты на цепочку с N объявлениями (песочницы Директа нет), плюс тесты + на сопоставление слепка и на отказ при несовпадении. +- **Живая сверка** — один раз, при go-live, с явного «go» владельца: одна кампания с минимальным + набором, рубильник включается только на время проверки. +- Проверка размеров/веса — обычными тестами валидатора. + +--- + +## 9. Вне scope + +- Удаление резчика `CampaignBannerService` / `BannerGenerator` — отдельное решение. +- «Часть A ВЫБОР» в мастере — сократилась до одного пути (клиент грузит готовые), отдельная работа + по экранам мастера. +- Переезд на «перформанс»-кампанию ради авто-подгонки из одной картинки — это отказ от модели + «за показы», не рассматривается. +- Клиентская витрина/оплата — уже есть в модуле рекламы, здесь не трогаем. + +--- + +## 10. Честно неизвестное — выяснить на реализации + +Это не заглушки в дизайне, а вопросы к живому кабинету. Ни один из них не меняет архитектуру, +но каждый надо закрыть до go-live: + +1. **Повторная загрузка набора** для той же кампании (клиент передумал и принёс другие картинки) — + старые креативы остаются в аккаунте мусором? Можно ли их удалять? Влияет только на чистоту + кабинета, не на работу. +2. **Ограничения Яндекса** на количество креативов в аккаунте и на темп загрузки — не упрёмся ли + при десятках клиентов. +3. **Сколько живёт вход** в кабинет Директа без действий. У МТС по замеру — недели; у Яндекса не + мерено. От этого зависит, как часто придётся заходить по удалённому рабочему столу. +4. **Точные экраны загрузки** — снимаются один раз руками на фазе реализации (см. §4). + +--- + +## 11. Критерии готовности + +1. Робот на боевом сервере, вход заведён через удалённый рабочий стол, хранитель держит его живым. +2. Клиент грузит файлы → портал проверяет и понятно ругается на неподходящие. +3. Робот сам берёт задание, грузит файлы, отчитывается «готово». +4. Портал сопоставляет слепок «до/после», проставляет номера креативов баннерам. +5. Запуск создаёт объявление на каждый баннер; модерация отслеживается по каждому. +6. Любой сбой из §5 даёт письмо со снимком экрана, кампания остаётся черновиком. +7. `db/03_service_bypass_policies.sql` перезапущен, `rls-reviewer` пройден, CHANGELOG схемы обновлён. diff --git a/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md b/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md new file mode 100644 index 00000000..8e59250f --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md @@ -0,0 +1,329 @@ +# Отказы модерации Яндекса — окно передачи между Яндексом и клиентом + +**Дата:** 28.07.2026. **Ветка:** `feat/reklama-yandex-pokazy`. +**Решение владельца, на котором стоит весь замысел:** + +> «Мы только окно передачи, сами не лезем. Яндекс — нам, мы — клиенту, клиент — нам, мы — Яндексу.» + +Портал ничего не решает за клиента и ничего не сочиняет от себя. Он переносит сообщения +в обе стороны и делает руками робота то, что человеку пришлось бы делать в кабинете. + +--- + +## 1. Что болит сегодня + +Проверено по коду 28.07.2026, не по памяти. + +**Причину отказа клиент не видит нигде.** Портал исправно спрашивает Яндекс про каждое +объявление и сохраняет пояснение модератора в `ad_campaign_banners.moderation_reason` +и `ad_campaigns.moderation_reason`. Но экран отчёта по кампании +(`CampaignReportDialog.vue`) показывает объявления из таблицы `ad_campaign_ads` — +это **старая модель** с текстовыми объявлениями, которую нынешний поток «за показы» +не заполняет вообще. Форма её заполнения (`CreativeForm.vue`) не подключена ни к одному +экрану. Клиент всегда видит «Объявлений пока нет», а рядом — красный ярлык «Отклонено» +без единого слова объяснения. Владелец в админке причину тоже не видит: она есть только +в базе. + +Это тот же класс поломки, что «значок воронки» и «статус ВК»: экран читает поле старой +модели, которое новый поток уже не пишет. + +**Кнопка «Исправить» ведёт в тупик.** У отклонённой кампании +(`CampaignList.vue`, строка 267) кнопка меняет надпись на «Исправить», но: + +- правка настроек заперта — `AdvertisingCampaignController::update()` отдаёт 409, как + только у кампании есть `yandex_campaign_id` или `yandex_segment_id`; +- перезаливка картинок заперта тем же признаком (`bannersLocked()`); +- запустить кампанию можно только из `draft` или `queued` — `CampaignLauncher::claim()` + отклонённую не пустит. + +То есть исправить нельзя ничего, кроме названия. + +**Документы Яндекса портал не увидит никогда.** Проверено по документации Яндекс Директа +(`ads.get`): у объявления есть только `Status` — `ACCEPTED | DRAFT | MODERATION | +PREACCEPTED | REJECTED | UNKNOWN` — и одна текстовая строка `StatusClarification`. +**Отдельного признака «запрошены документы» в программном интерфейсе нет**, и способа +приложить файл через него — тоже нет. Всё это Яндекс показывает людям в кабинете. + +Отсюда главный вывод замысла: **глаза и руки в кабинете — это робот**. Он уже туда ходит, +уже держит живой вход, уже умеет носить файлы. Владельца в цепочку звать не нужно. + +--- + +## 2. Как будет работать + +Круг замкнутый, повторяется до победы, человек в нём не участвует: + +1. Портал при очередном опросе модерации видит: **Яндекс отклонил**. +2. Портал ставит роботу задание **«разведка»**: сходить в кабинет, открыть это объявление, + прочитать причину целиком, увидеть требование документов, снять экран. +3. Робот докладывает. Портал кладёт доклад в **переписку по кампании**. Клиенту уходит + письмо и загорается колокольчик. +4. Клиент правит: меняет картинку, при необходимости прикладывает документ, пишет ответ. +5. Портал ставит роботу задание **«отнести»**: отвезти в кабинет новую картинку и документ, + отправить на модерацию. +6. Возврат к пункту 1, пока Яндекс не примет. + +Почему разведка нужна, хотя причина и так приходит по интерфейсу: `StatusClarification` — +одна строка, часто общая («не соответствует требованиям»). Настоящий разбор и требование +бумаг живут в кабинете, и увидеть их может только тот, у кого есть глаза. + +--- + +## 3. Три куска + +Каждый работает сам по себе и выкатывается отдельно. + +| Кусок | Что даёт | Робот нужен | Живой кабинет нужен | +|---|---|---|---| +| **1. Переписка** | Клиент наконец видит, за что его отклонили | нет | нет | +| **2. Оживление** | Кнопка «Исправить» начинает работать | да, уже умеет | нет | +| **3. Разведка и документы** | Робот читает кабинет и носит бумаги | да, новое умение | **да** | + +🔴 **Кусок 3 нельзя начинать с кода.** Экран с причиной отказа и экран с документами мы +не видели ни разу. Разметку для загрузки картинок снимали живьём — потому она и работает +(`bots/yandex-creatives/docs/cabinet-flow.md`). Писать вторую половину робота по +воображаемым экранам — верный способ получить зелёные тесты и мёртвую программу. + +Отказ можно вызвать **нарочно**: залить заведомо непроходное объявление и посмотреть, что +покажет кабинет. Один заход вместе с владельцем, полчаса — и разметка снята с натуры. +До этого захода кусок 3 не начинается. + +--- + +## 4. Кусок 1 — переписка по кампании + +### Что появляется + +Новая таблица `ad_campaign_messages`. Одна строка — одно сообщение. + +| Поле | Смысл | +|---|---| +| `tenant_id`, `campaign_id` | чьё и по какой кампании; RLS по `tenant_id` | +| `author` | `yandex` — слова Яндекса, откуда бы мы их ни взяли: из интерфейса или увиденные роботом в кабинете; `client` — ответ клиента; `system` — служебная отметка портала вроде «документ отправлен в Яндекс» | +| `banner_id` | к какому размеру блока относится, если относится | +| `body` | текст; для `yandex` — пояснение модератора как есть, без нашей трактовки | +| `file_path` | приложенный клиентом файл, если есть | +| `file_name`, `file_size`, `file_mime` | как показать и чем отдать | +| `created_at` | когда | + +Отдельная таблица, а не поле в кампании: сообщений много, они растут, и нужна история — +через месяц никто не вспомнит, что кому отправляли. + +### Правила + +- **Пояснение Яндекса кладём как есть.** Ни сокращений, ни пересказа. Дубли не плодим: + если текст совпадает с последним сообщением `yandex` по этому же баннеру — не пишем. + Иначе каждый опрос модерации добавлял бы одну и ту же строку. +- **Текст храним целиком.** Колонка `body` — `text`, без предела 255. 🪤 Именно предел + колонки `moderation_reason` уже валил обход модерации целиком (починено 28.07.2026); + повторять эту мину нельзя. В `moderation_reason` по-прежнему кладём обрезанное — оно + для ярлыка, а полный текст живёт в переписке. +- **Файл клиента лежит на закрытом диске**, наружу отдаётся только через ручку портала + с проверкой тенанта — как уже сделано для картинок баннеров. +- **Что принимаем:** `pdf`, `jpg`, `png`, до 10 МБ. Больше — отказ 422 с человеческим + текстом. Проверяем и расширение, и настоящий тип файла. +- **Наценки в переписке быть не может** — `yandex_cost_rub`, `ad_margin_percent` в текст + сообщений не попадают никогда. + +### Кому что прилетает + +- **Клиенту** — письмо на почту и колокольчик (`in_app_notifications`, механизм есть) + на каждое новое сообщение от `yandex`. На служебные отметки портала (`system`) — + ни письма, ни колокольчика: «документ отправлен в Яндекс» это не новость, а расписка. +- **Владельцу** — писем по обычным отказам **нет**. Письмо уходит только тогда, когда + робот пришёл в кабинет и **не понял, что видит**: страница не открылась, разметка + не совпала. Это не «дёргать по каждому чиху», это чтобы цепочка не встала молча. + +### Экраны + +- **Карточка кампании** — лента сообщений: кто, когда, текст, файл. Поле ответа и кнопка + «приложить файл». Ярлык «Отклонено» получает подпись — первую строку причины. +- **Админка** — список «ждёт разбора»: кампании, где робот растерялся. Не для обычных + отказов, а именно для сбоев разведки. + +--- + +## 5. Кусок 2 — оживление кампании + +### Что делает «Исправить» + +Кнопка у **полностью отклонённой** кампании возвращает её в черновик: + +- статус → `draft`; +- у баннеров, которые Яндекс отклонил, обнуляются `yandex_creative_id` и `yandex_ad_id` + — им нужны новая картинка и новое объявление; +- номера кампании, группы и сегмента (`yandex_campaign_id`, `yandex_ad_group_id`, + `yandex_segment_id`) **сохраняются** — на них держится возобновляемый запуск, второй + раз то же самое в Яндексе создавать не надо; +- отклонённые объявления в кабинете удаляются через `ads.delete`, чтобы не копить мусор. + Не удалилось — не беда: отклонённое объявление не показывается и денег не стоит, + пишем в журнал и идём дальше. + +Дальше клиент работает **привычными экранами**: меняет картинку, правит адрес сайта, +жмёт обычную кнопку «Запустить». Никакого второго пути запуска не появляется — работает +существующий `CampaignLauncher`, который по построению доигрывает начатое: пропускает +баннеры с уже проставленными номерами и переиспользует созданные сущности Яндекса. + +### Замок на правку + +Сегодня правка заперта признаком «кампания уже заведена в Яндексе». Замок остаётся, +но получает **одно узкое исключение**: `status === rejected`. Обоснование — у отклонённой +кампании показов нет и денег на ней нет, расходиться с Яндексом нечему. + +🪤 Исключение писать **по статусу, а не по «есть ли номер кампании»** — иначе оно +откроет правку и работающей рекламе. + +### Деньги + +- При отказе деньги уже вернулись клиенту целиком — это **выход №2** + (`SyncCampaignModerationJob`), он не трогается. +- Оживление денег **не трогает вообще**. Заморозка ставится там же, где и всегда — + внутри `CampaignLauncher`, когда объявления созданы. +- 🔴 **Пятого места разморозки не появляется.** Их ровно четыре, и после этой работы + должно остаться четыре. Проверять после каждой правки. +- Если денег на счету стало меньше, чем нужно, — запуск отказывает так же, как всегда. + Никакого особого случая для оживления. + +### Чего в этом куске НЕ делаем + +- **Частичный отказ** — когда часть объявлений принята и кампания крутится. Клиенту + показываем в переписке, какой размер отвалился и почему, но чинить его в живой кампании + пока нельзя: это отдельный путь в обход `claim()`, который пускает только черновики. + Записано как следующий кусок, не как забытое. +- **Смена адреса сайта в живой кампании** — по той же причине. + +--- + +## 6. Кусок 3 — робот: разведка и документы + +### Два новых вида задания + +У `ad_creative_jobs` появляется колонка `kind`: `upload` (то, что робот делает сейчас), +`inspect` (разведка), `deliver` (отнести документ). Старые задания — `upload`. + +🪤 Частичный уникальный индекс `uq_creative_job_single_taken` остаётся как есть: у робота +один браузерный профиль, задания идут по одному. Новые виды этого не меняют. + +### Разведка `inspect` + +Ставится порталом, когда опрос модерации увидел отказ. Робот: + +1. открывает кабинет, проверяет живость входа — как сейчас; +2. находит объявление по номеру `yandex_ad_id`; +3. снимает: полный текст причины, признак «требуют документы» и что именно, снимок экрана; +4. докладывает порталу. + +Портал кладёт доклад в переписку от имени `yandex`, снимок — файлом. + +**Робот ничего не меняет в кабинете.** Существующий тест-сторож «не нажимать Сохранить» +распространяется и на разведку. + +### Доставка `deliver` + +Ставится, когда клиент прислал документ. Робот везёт файл в кабинет и отправляет +на модерацию. Докладывает порталу; портал пишет в переписку «отправлено в Яндекс». + +Это **единственное место**, где робот меняет что-то в кабинете помимо загрузки картинок, +и его разметку обязательно снимать живьём. + +### Если робот растерялся + +Не нашёл объявление, не совпала разметка, кабинет не открылся — задание закрывается +сбойным, письмо уходит **владельцу** со снимком экрана, в переписку клиенту при этом +ничего не сочиняем. Молчать нельзя: иначе клиент ждёт ответа, которого не будет. + +--- + +## 7. Что может пойти не так + +| Беда | Что делает портал | +|---|---| +| Яндекс отклонил, робот не смог посмотреть | Причина из интерфейса всё равно в переписке — она короткая, но лучше пустоты. Владельцу письмо | +| Клиент прислал файл, а Яндекс уже принял рекламу | Файл сохраняем, в переписку пишем «уже принято, документ не понадобился». Робота не гоняем | +| Тот же отказ пришёл дважды | Дубль в переписку не пишем, второе задание разведки не ставим | +| Клиент жмёт «Исправить» дважды | Второе нажатие — прежний отказ 409, как у запуска | +| Файл клиента — не документ, а что попало | Отказ 422 до сохранения; на диск не кладём | +| Рубильник Директа выключен | Ни одного обращения к Яндексу и ни одного задания роботу. Переписка при этом работает | + +--- + +## 8. Приёмочный лист + +Это и есть проверяемые требования. Каждая строка — отдельный тест, каждая защита +проверяется **вырезанием**: сломать боевой код, убедиться, что тест покраснел, вернуть. + +### Кусок 1 — переписка + +- [ ] Пояснение Яндекса появляется в переписке само, при первом же опросе после отказа +- [ ] Текст сохраняется **целиком**, длиннее 255 знаков в том числе +- [ ] Повторный опрос с тем же текстом второго сообщения не создаёт +- [ ] Клиент видит переписку своей кампании и **не видит** чужую +- [ ] Клиент отправляет ответ с файлом; файл лежит на закрытом диске +- [ ] Файл отдаётся только своему тенанту; чужому — отказ +- [ ] Файл не того типа или тяжелее 10 МБ — отказ 422, на диск ничего не легло +- [ ] На новое сообщение от Яндекса клиенту уходит письмо и загорается колокольчик +- [ ] Ярлык «Отклонено» на списке кампаний показывает первую строку причины +- [ ] В переписке нет ни наценки, ни `yandex_cost_rub`, ни `ad_margin_percent` + +### Кусок 2 — оживление + +- [ ] «Исправить» у отклонённой кампании возвращает её в черновик +- [ ] Номера кампании, группы и сегмента Яндекса при этом **уцелевают** +- [ ] У отклонённых баннеров номера креатива и объявления обнулены +- [ ] Правка картинок и настроек у отклонённой кампании разрешена +- [ ] У **работающей** кампании правка по-прежнему заперта — исключение не протекло +- [ ] После правки обычная кнопка «Запустить» доводит кампанию до модерации +- [ ] Второй одновременный «Исправить» получает отказ 409 +- [ ] Мест разморозки денег по-прежнему **четыре** +- [ ] Не хватило денег — обычный отказ запуска, без особого случая + +### Кусок 3 — робот + +- [x] Отказ по интерфейсу ставит задание разведки ровно один раз (28.07.2026) +- [x] Робот с заданием разведки **ничего не меняет** в кабинете +- [x] Доклад разведки попадает в переписку вместе со снимком экрана +- [x] Робот не понял, что видит, — задание сбойное, письмо владельцу, клиенту ничего + не сочиняем +- [x] ~~Документ клиента доезжает до кабинета и уходит на модерацию~~ 🔴 **ОТМЕНЕНО + ПО ФАКТУ: такой дороги в кабинете Яндекса нет.** Проверено двумя нарочными отказами + (обычная и лицензируемая тематика) — в окне отказа ноль полей для файла, документы + Яндекс принимает только снаружи: чат поддержки или `forms.yandex.ru`. + См. `cabinet-flow.md` §7.5–7.6. + **Заменено решением владельца 28.07.2026:** документ принимаем как прежде, но портал + сразу говорит клиенту правду («передать автоматически нельзя, отнесём сами»), + а владельцу уходит письмо — иначе обещание было бы пустым +- [x] Рубильник Директа выключен — ни одного задания и ни одного обращения к Яндексу + +### Общее + +- [ ] Оба набора проверок зелёные: портал и робот +- [ ] Миграции прошли `rls-reviewer`, записаны в `db/CHANGELOG_schema.md` +- [ ] 🔴 После выката перезапущен `db/03_service_bypass_policies.sql` — иначе служебные + роли увидят ноль, а журнал будет зелёный + +--- + +## 9. Что осознанно НЕ делаем + +Записано, чтобы через месяц это не назвали недосмотром. + +- **Портал не ищет «зависшие» объявления по таймеру.** Спусковой крючок один — отказ + по программному интерфейсу. Решение владельца 28.07.2026: «какие двое суток, мы можем + получить по апи что отклонили». Если однажды выяснится, что Яндекс держит объявление + в «на модерации» и молча ждёт бумаг, — это будет отдельный разговор, а не тихая правка. +- **Владельца по обычным отказам не беспокоим.** Только когда робот растерялся. +- **Частичный отказ не чиним** — см. §5. +- **Старая таблица `ad_campaign_ads` не трогается.** Она мёртвая, но удаление мёртвого + кода — отдельное решение владельца. + +--- + +## 10. Открытые вопросы + +- **Разметка кабинета для разведки и документов** — снимается живьём вместе с владельцем, + до начала куска 3. Отказ можно вызвать нарочно. +- **Сколько хранить приложенные документы.** Это чужие бумаги: свидетельства, лицензии. + Вечно им лежать не стоит. Решает владелец. +- **Д4** (из прошлой работы, не закрыт): как писать проводку при нехватке денег. Владелец + ответил 28.07.2026 «двумя строчками: списано и отдельно недобор» — но это отдельная + работа, в этот замысел не входит. diff --git a/pa11y.config.json b/pa11y.config.json index 76437b26..932b4afa 100644 --- a/pa11y.config.json +++ b/pa11y.config.json @@ -158,6 +158,48 @@ "wait for path to be /reminders" ] }, + { + "url": "http://localhost:8000/advertising/yandex", + "screenCapture": "./bin/a11y-screenshots/live-auth-22-advertising-yandex.png", + "actions": [ + "navigate to http://localhost:8000/login", + "wait for element input[autocomplete=\"email\"] to be visible", + "set field input[autocomplete=\"email\"] to admin@demo.local", + "set field input[autocomplete=\"current-password\"] to password", + "click element button[type=\"submit\"]", + "wait for path to be /dashboard", + "navigate to http://localhost:8000/advertising/yandex", + "wait for path to be /advertising/yandex" + ] + }, + { + "url": "http://localhost:8000/advertising/wallet", + "screenCapture": "./bin/a11y-screenshots/live-auth-23-advertising-wallet.png", + "actions": [ + "navigate to http://localhost:8000/login", + "wait for element input[autocomplete=\"email\"] to be visible", + "set field input[autocomplete=\"email\"] to admin@demo.local", + "set field input[autocomplete=\"current-password\"] to password", + "click element button[type=\"submit\"]", + "wait for path to be /dashboard", + "navigate to http://localhost:8000/advertising/wallet", + "wait for path to be /advertising/wallet" + ] + }, + { + "url": "http://localhost:8000/admin/advertising", + "screenCapture": "./bin/a11y-screenshots/live-auth-24-admin-advertising.png", + "actions": [ + "navigate to http://localhost:8000/login", + "wait for element input[autocomplete=\"email\"] to be visible", + "set field input[autocomplete=\"email\"] to admin@demo.local", + "set field input[autocomplete=\"current-password\"] to password", + "click element button[type=\"submit\"]", + "wait for path to be /dashboard", + "navigate to http://localhost:8000/admin/advertising", + "wait for path to be /admin/advertising" + ] + }, { "url": "http://localhost:8000/admin/tenants", "screenCapture": "./bin/a11y-screenshots/live-auth-16-admin-tenants.png", diff --git a/ПИЛОТ.md b/ПИЛОТ.md index 11c2d103..9f10ca57 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -16,6 +16,48 @@ --- +## 🆕 ОБНОВЛЕНИЕ 29.07.2026 — «Реклама за показы» + окно передачи отказов модерации ВЫКАЧЕНЫ + +**Выкачен весь модуль целиком, первый раз.** `main` = `dcf97067` (gitea), выкат по рунбуку +`docs/superpowers/runbooks/2026-06-18-gitea-prod-deploy-pipeline.md`. Простой — только +на время overlay'я; портал и лендинг после подъёма отвечают **200**, журнал чист. + +- **Миграции: 24 штуки**, все `ad_*`, применены на ЖИВОЙ Managed-кластер `c9q2cvtjpq3hgq6l0r96`. + 🔑 **Способ лучше рунбучного:** вместо ручного переливания SQL — `artisan config:clear`, + затем `sudo -u www-data env DB_USERNAME=crm_migrator DB_PASSWORD=… php artisan migrate --force`, + затем `artisan optimize`. Env-override не работал только из-за КЭША конфига; сбросил кэш — + заработал. Laravel сам ведёт таблицу `migrations`, ручные INSERT'ы не нужны. + 🪤🪤 **Почему НЕ через `--pretend` → psql:** pretend схлопывает многострочные `DO $$…$$` + в ОДНУ строку, а внутри них комментарии `--`. В файле такой комментарий съедает всё + до конца строки — вместе с самими GRANT'ами и закрывающим `END $$;`. Половина прав + (в т.ч. **v9.17**) не доехала бы, либо файл упал бы на середине. +- **`db/03_service_bypass_policies.sql` перезапущен** → **57** политик `srv_bypass` + (было 44 на полигоне); `ad_creative_jobs`, `ad_campaign_messages`, `ad_campaign_banners` + покрыты. Проверено запросом, не на веру. +- **Права проверены живыми запросами:** v9.17 (`crm_admin_user` INSERT в `ad_campaign_messages` + и нумератор) — есть; колокольчик `in_app_notifications` — есть; очередь заданий робота + (`crm_app_user` INSERT / `crm_admin_user` UPDATE) — есть; `crm_supplier_worker` INSERT + в ленту — есть. +- **`.env` дополнен** (бэкап `/home/ubuntu/env-backup-20260729-predeploy.bak`): + `YANDEX_DIRECT_ENABLED=false` — теперь ЯВНО, а не по умолчанию; + `MONITORING_ALERT_EMAIL=info@liderra.ru` — решение владельца 29.07 (туда уходит письмо + о приложенном клиентом документе; подставляемый кодом `ops@liderra.ru` мог не существовать, + и письма пропадали бы молча). +- **Что НЕ включено намеренно:** рубильник выключен → три джоба (`SyncCampaignAudienceJob`, + `ChargeCampaignSpendJob`, `SyncCampaignModerationJob`) проверяют его первой строкой и + не делают ничего. `CREATIVE_ROBOT_TOKEN` не задан → канал робота **закрыт**, проверено + живьём: `/api/creative-robot/next` → **401**. +- 🔴 **Разведка на бою НЕ заработает, пока не решён вопрос «где живёт робот»:** ему нужен + браузер с живым входом в кабинет Яндекса, на сервере такого нет. Код и база готовы, + включение — отдельный маленький шаг. +- **Попутно закрыт NO-GO предполёта:** кэш конфига на проде был собран 26.07 14:36 при + `.env` от 27.07 05:01 — почти двое суток портал жил по устаревшим настройкам. Пересобран + `sudo -u www-data` (квирк 107), теперь `www-data:www-data`, 29.07 05:25. +- **Бэкапы до выката:** код `/tmp/app-backup-2026-07-29.tgz`, облачный бэкап кластера + 29.07 00:16 UTC (за 5 ч до выката). Откат — §8 рунбука. +- **Не связано с выкатом:** в `queue:failed` висит одно старое задание `CsvReconcileJob` + от 20.07 — было до нас, не трогали. + ## 🆕 ОБНОВЛЕНИЕ 23.07.2026 — хотфикс значка прогрева в воронке (на боевом) **Выкачен ОДИН backend-файл** `SalesProspectController.php` (коммит `8033debf`, gitea/main).