diff --git a/app/app/Services/Advertising/CampaignLauncher.php b/app/app/Services/Advertising/CampaignLauncher.php index 430bdbde..f78a98d2 100644 --- a/app/app/Services/Advertising/CampaignLauncher.php +++ b/app/app/Services/Advertising/CampaignLauncher.php @@ -10,6 +10,7 @@ use App\Exceptions\Advertising\BudgetBelowYandexMinimumException; use App\Models\AdCampaign; use App\Models\AdCampaignBanner; use App\Services\Sales\YandexAudienceClient; +use Carbon\CarbonImmutable; use DateTimeInterface; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -208,6 +209,10 @@ final class CampaignLauncher throw new AudienceTooSmallException(count($phones)); // Яндекс не запустит сегмент <100 } + // Период показа нужен ДО денег: минимум, ниже которого Директ не берёт кампанию, + // считается именно от длины периода (см. проверку минимума ниже). + [$startDate, $endDate] = $this->resolvePeriod($campaign); + // Деньги: только bcmath, микросы — целые. $margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'); $share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ @@ -223,9 +228,20 @@ final class CampaignLauncher // отказ 30.07.2026: «Budget for this period cannot be less than 600 rub.» — клиент // видел его как есть, по-английски, и не понимал, что от него хотят. // + // 🔴 Минимум НЕ постоянный: он считается от длины периода — 300 ₽ за каждый + // календарный день, считая оба края. Первый живой отказ мы приняли за постоянные + // 600 ₽, и на бою тут же напоролись на второй: та же кампания на неделю потребовала + // 2400 ₽ и снова показала клиенту английский текст. Дни считаем ровно так, как их + // считает Яндекс: от StartDate до EndDate включительно. + // // Проверяем ЗДЕСЬ — до первого обращения к Яндексу: в кабинете ничего не создаётся, // деньги не морозятся, кампания остаётся черновиком. - $minSpendRub = (string) config('services.yandex_direct.min_spend_limit_rub'); + $dneyVPeriode = CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1; + $minSpendRub = bcmul( + (string) config('services.yandex_direct.min_spend_limit_rub_per_day'), + (string) $dneyVPeriode, + 2, + ); $spendLimitRub = bcdiv((string) $spendLimitMicros, '1000000', 2); if (bccomp($spendLimitRub, $minSpendRub, 2) < 0) { // Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума. @@ -238,8 +254,6 @@ final class CampaignLauncher ); } - [$startDate, $endDate] = $this->resolvePeriod($campaign); - $freqImpr = (int) ($campaign->frequency ?? 1); $freqPeriod = (int) ($campaign->frequency_period_days ?? 1); @@ -317,18 +331,40 @@ final class CampaignLauncher $pending = $banners->filter(fn ($banner) => (int) $banner->yandex_ad_id === 0); $this->assertCreativeSizesMatch($direct, $pending); + $sozdannyeAds = []; foreach ($banners as $banner) { if ((int) $banner->yandex_ad_id > 0) { continue; } $adId = $direct->addCpmBannerAd($adGroupId, (int) $banner->yandex_creative_id, $href); + $sozdannyeAds[] = $adId; $banner->update([ 'yandex_ad_id' => $adId, 'moderation_status' => AdCampaignBanner::MOD_MODERATION, ]); } + // 2б) Отдать объявления на проверку и включить показ. + // + // 🔴 Создать объявление НЕ значит запустить рекламу. Яндекс кладёт всё созданное + // черновиком и сам проверку не начинает: кампания, группа и объявления остаются + // DRAFT/OFF, показов нет, деньги не тратятся — и портал при этом рапортует + // «на модерации». Поймано живой приёмкой 30.07.2026: кабинет показал 15 объявлений + // в черновиках через двадцать минут после «успешного» запуска. Класс тот же, что и + // у остальных молчаливых сбоев: успех в журнале, пусто в жизни. + // + // Порядок обязателен: пока кампания черновик, включить её нельзя — Директ отвечает + // «кампания является черновиком и не может быть остановлена». Сначала модерация, + // она переводит кампанию в MODERATION, и только потом включение показа. + // + // На модерацию отправляем объявления, созданные ИМЕННО этим заходом: повторная + // отправка уже проверяемого объявления — отказ по позиции, и он порвал бы + // возобновляемый запуск. Включение показа безобидно при любом повторе: на уже + // включённой кампании Яндекс отвечает предупреждением, а не отказом. + $direct->moderateAds($sozdannyeAds); + $direct->resumeCampaign($campaignId); + // 3) Заморозить клиентскую сумму за смету показов в кошельке (Часть A). $this->wallet->freeze((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, $freezeClientRub); diff --git a/app/app/Services/Advertising/YandexDirectClient.php b/app/app/Services/Advertising/YandexDirectClient.php index 7ba86e7d..ff6a3117 100644 --- a/app/app/Services/Advertising/YandexDirectClient.php +++ b/app/app/Services/Advertising/YandexDirectClient.php @@ -309,6 +309,26 @@ final class YandexDirectClient $this->call('v5/ads', 'delete', ['SelectionCriteria' => ['Ids' => $adIds]]); } + /** + * Ads.moderate — отправить объявления на модерацию Яндекса. + * + * 🔴 Без этого вызова созданное объявление остаётся ЧЕРНОВИКОМ и не показывается + * никогда: Яндекс не начинает проверку сам. Живая приёмка 30.07.2026 — портал + * отрапортовал «на модерации», а в кабинете лежали кампания, группа и 15 объявлений + * в статусе DRAFT/OFF. Отбор строго по `Ids`: на `CampaignIds` Директ отвечает + * «отсутствует обязательный параметр Ids». + * + * @param list $adIds + */ + public function moderateAds(array $adIds): void + { + if ($adIds === []) { + return; + } + + $this->call('v5/ads', 'moderate', ['SelectionCriteria' => ['Ids' => $adIds]]); + } + /** Campaigns.suspend — пауза показа. */ public function suspendCampaign(int $campaignId): void { @@ -394,7 +414,7 @@ final class YandexDirectClient // Поймано живым запуском 29.07.2026: RetargetingLists.add вернул позицию без `Id`, // и портал упал ошибкой PHP «Undefined array key "Id"» — ни клиенту, ни в журнал // не попало ни слова о том, ЧТО сказал Яндекс. Теперь слова Яндекса выходят наружу. - foreach (['AddResults', 'UpdateResults', 'DeleteResults'] as $key) { + foreach (['AddResults', 'UpdateResults', 'DeleteResults', 'ModerateResults', 'ResumeResults'] as $key) { foreach ($json['result'][$key] ?? [] as $item) { if (! empty($item['Errors'])) { throw new RuntimeException( diff --git a/app/config/services.php b/app/config/services.php index 0e588b3b..8057cb74 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -41,9 +41,14 @@ return [ // от перерасхода, НЕ клиентская цена). 'spend_limit_guard_multiplier' => (float) env('YANDEX_DIRECT_SPEND_GUARD', 1.2), // Минимальный бюджет кампании за период, ниже которого Директ её просто не берёт. - // Значение — из живого отказа 30.07.2026: «Budget for this period cannot be less - // than 600 rub.». В настройке, а не в коде: Яндекс может поменять порог. - 'min_spend_limit_rub' => (string) env('YANDEX_DIRECT_MIN_SPEND_RUB', '600'), + // Порог НЕ постоянный — он растёт с длиной периода: 300 ₽ за каждый календарный + // день, считая оба края. Два живых отказа 30.07.2026 дали обе точки: + // период 30.07–31.07 (2 дня) → «cannot be less than 600 rub.» = 2 × 300 + // период 30.07–06.08 (8 дней) → «cannot be less than 2400 rub.» = 8 × 300 + // Первый отказ мы приняли за постоянный порог — и проверка пропускала любую + // кампанию длиннее двух дней прямо в тот же английский отказ Яндекса. + // В настройке, а не в коде: Яндекс может поменять ставку. + 'min_spend_limit_rub_per_day' => (string) env('YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY', '300'), ], // Рекламная аудитория кандидатов в ВК (Task 8, 21.07.2026). Программный доступ diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php index 61ccd263..d2f0160d 100644 --- a/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php +++ b/app/tests/Feature/Advertising/AdvertisingCampaignEndpointTest.php @@ -863,6 +863,9 @@ it('отвечает «подождите», пока рекламная сис 'mode' => AdCampaign::MODE_MANUAL, 'use_uploaded_list' => true, 'estimated_impressions' => 10000, + // Срок показа задан явно: минимум площадки растёт с длиной периода, и без этого + // проверка сметы сработала бы раньше — до вопроса о готовности аудитории. + 'run_days' => 1, 'client_cpm_rub' => '120.00', 'landing_url' => 'https://liderra.ru', 'yandex_segment_id' => 900001, @@ -917,6 +920,9 @@ it('объясняет клиенту, какая смета нужна, вме 'use_uploaded_list' => true, // Ровно та кампания, что стояла на бою. 'estimated_impressions' => 1693, + // Один день показа = период из двух календарных дней ⇒ минимум площадки 600 ₽. + // Порог растёт с длиной периода, поэтому срок здесь задан явно. + 'run_days' => 1, 'client_cpm_rub' => '120.00', 'landing_url' => 'https://liderra.ru', ]); diff --git a/app/tests/Feature/Advertising/CampaignLauncherTest.php b/app/tests/Feature/Advertising/CampaignLauncherTest.php index 3318664d..12086a8d 100644 --- a/app/tests/Feature/Advertising/CampaignLauncherTest.php +++ b/app/tests/Feature/Advertising/CampaignLauncherTest.php @@ -379,7 +379,10 @@ it('creates one ad per included banner and stores the ad id on each banner', fun // Каждый вызов ads.add отдаёт свой номер объявления. '*/json/v5/ads' => Http::sequence() ->push(['result' => ['AddResults' => [['Id' => 5551]]]]) - ->push(['result' => ['AddResults' => [['Id' => 5552]]]]), + ->push(['result' => ['AddResults' => [['Id' => 5552]]]]) + // Третьим по тому же адресу идёт ads.moderate — отправка созданных объявлений + // на проверку Яндекса. + ->push(['result' => ['ModerateResults' => [['Id' => 5551], ['Id' => 5552]]]]), ]); $tenant = Tenant::factory()->create(); @@ -402,8 +405,8 @@ it('creates one ad per included banner and stores the ad id on each banner', fun // 2 запроса на сегмент Аудиторий (заливка csv + подтверждение) + 1 вопрос «готов ли // сегмент» + retargetinglists + campaigns + adgroups + audiencetargets + один // creatives.get на сверку размеров всего набора + по одному ads.add на каждый - // баннер (2) = 10. - Http::assertSentCount(10); + // баннер (2) + ads.moderate + campaigns.resume = 12. + Http::assertSentCount(12); }); it('skips banners that are switched off by the client', function () { @@ -532,11 +535,13 @@ it('resumes an interrupted launch: no second Yandex campaign, no duplicated ads' str_contains($url, '/confirm') => 'segment_confirm', str_contains($url, '/v1/management/segments') => 'segments_list', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', - str_contains($url, '/json/v5/campaigns') => 'campaigns', + // resume отделяем от add: «кампанию не создавали заново» и «показ включили» — + // это разные утверждения, а адрес у них один. + str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : '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', + str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'), default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; @@ -546,6 +551,8 @@ it('resumes an interrupted launch: no second Yandex campaign, no duplicated ads' 'segments_list' => otvetSegmentGotov(), 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + 'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]), + 'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5551]]]]), 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), 'adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), 'audiencetargets' => Http::response(['result' => ['AddResults' => [['Id' => 444]]]]), @@ -622,11 +629,13 @@ it('re-creates the ad group when the launch broke between the group and the audi str_contains($url, '/confirm') => 'segment_confirm', str_contains($url, '/v1/management/segments') => 'segments_list', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', - str_contains($url, '/json/v5/campaigns') => 'campaigns', + // resume отделяем от add: «кампанию не создавали заново» и «показ включили» — + // это разные утверждения, а адрес у них один. + str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : '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', + str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'), default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; @@ -636,6 +645,8 @@ it('re-creates the ad group when the launch broke between the group and the audi 'segments_list' => otvetSegmentGotov(), 'retargetinglists' => Http::response(['result' => ['AddResults' => [['Id' => 111]]]]), 'campaigns' => Http::response(['result' => ['AddResults' => [['Id' => 222]]]]), + 'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]), + 'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5551]]]]), 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), 'adgroups' => Http::response(['result' => ['AddResults' => [['Id' => 333]]]]), 'audiencetargets' => $breakTarget @@ -1057,11 +1068,13 @@ it('после оживления обычный запуск доводит к str_contains($url, '/confirm') => 'segment_confirm', str_contains($url, '/v1/management/segments') => 'segments_list', str_contains($url, '/json/v5/retargetinglists') => 'retargetinglists', - str_contains($url, '/json/v5/campaigns') => 'campaigns', + // resume отделяем от add: «кампанию не создавали заново» и «показ включили» — + // это разные утверждения, а адрес у них один. + str_contains($url, '/json/v5/campaigns') => (($request->data()['method'] ?? '') === 'resume' ? 'campaigns_resume' : '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', + str_contains($url, '/json/v5/ads') => (($request->data()['method'] ?? '') === 'moderate' ? 'ads_moderate' : 'ads'), default => 'other', }; $calls[$key] = ($calls[$key] ?? 0) + 1; @@ -1069,6 +1082,8 @@ it('после оживления обычный запуск доводит к return match ($key) { 'creatives' => Http::response(['result' => ['Creatives' => creativesOfSeedBanners()]]), 'ads' => Http::response(['result' => ['AddResults' => [['Id' => 5561]]]]), + 'ads_moderate' => Http::response(['result' => ['ModerateResults' => [['Id' => 5561]]]]), + 'campaigns_resume' => Http::response(['result' => ['ResumeResults' => [['Id' => 222]]]]), default => Http::response([], 404), }; }); @@ -1150,7 +1165,8 @@ it('подсказывает клиенту минимальную смету п $tenant = Tenant::factory()->create(); app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); - $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 1693]); + // Один день показа = период из двух календарных дней ⇒ минимум 2 × 300 = 600 ₽. + $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 1693, 'run_days' => 1]); seedAudience($campaign, 200); seedBanners($campaign, ['300x250' => 4242]); @@ -1164,6 +1180,38 @@ it('подсказывает клиенту минимальную смету п } }); +/** + * Минимум Директа растёт с длиной периода, и эта смета — ровно та, что прошла старую + * проверку на бою и всё равно получила английский отказ Яндекса. + * + * 6950 показов дают Яндексу 600.48 ₽ — постоянного порога в 600 ₽ хватало. Но кампания + * на неделю занимает 8 календарных дней, и Директ требует 8 × 300 = 2400 ₽. + */ +it('считает минимум по длине периода, а не одной постоянной цифрой', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, ['estimated_impressions' => 6950, 'run_days' => 7]); + seedAudience($campaign, 200); + seedBanners($campaign, ['300x250' => 4242]); + + try { + app(CampaignLauncher::class)->launch($campaign); + expect('запуск прошёл')->toBe('ожидали отказ: неделя требует 2400 ₽, а не 600 ₽'); + } catch (BudgetBelowYandexMinimumException $e) { + // 2400 ₽ ÷ (запас 1.2 × 72 ₽ за тысячу) = 27777.7 → округляем вверх. + expect($e->minImpressions)->toBe(27778) + ->and($e->minClientRub)->toBe('3333.36'); + } + + // В Яндекс не ходили вовсе: отказ рождается до первого запроса. + Http::assertNothingSent(); + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_DRAFT); +}); + /** * Аудитория ЕСТЬ, но Яндекс ещё сводит номера с людьми. Готовность определяется * ТОЛЬКО по can_create_dependent: статус is_processed — это «обрабатывается», не «готов». @@ -1214,3 +1262,87 @@ it('пропускает запуск, когда Яндекс отдаёт can_ expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_PENDING_MODERATION); }); + +/** + * Создать объявления — не значит запустить рекламу. + * + * Яндекс кладёт всё созданное черновиком и проверку сам не начинает. Живая приёмка + * 30.07.2026: портал отрапортовал «на модерации», а в кабинете висели кампания, группа + * и 15 объявлений в DRAFT/OFF — показов не было бы никогда. Запуск обязан сам отдать + * объявления на проверку и включить показ. + */ +it('отдаёт объявления на модерацию и включает показ кампании', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 200); + seedBanners($campaign, ['300x250' => 4242, '728x90' => 4243]); + + app(CampaignLauncher::class)->launch($campaign); + + // Отбор строго по Ids: на CampaignIds Директ отвечает «отсутствует обязательный + // параметр Ids» — именно так молча ломалась отправка на бою. + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/ads') + && ($request->data()['method'] ?? null) === 'moderate' + && ($request->data()['params']['SelectionCriteria']['Ids'] ?? []) === [555, 555]; + }); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/json/v5/campaigns') + && ($request->data()['method'] ?? null) === 'resume' + && ($request->data()['params']['SelectionCriteria']['Ids'] ?? []) === [222]; + }); +}); + +/** Без модерации показ включать нельзя: черновик Директ включать отказывается. */ +it('сначала отдаёт на модерацию и только потом включает показ', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id); + seedAudience($campaign, 200); + seedBanners($campaign, ['300x250' => 4242]); + + app(CampaignLauncher::class)->launch($campaign); + + $poryadok = []; + foreach (Http::recorded() as [$request]) { + $metod = $request->data()['method'] ?? null; + if ($metod === 'moderate' || $metod === 'resume') { + $poryadok[] = $metod; + } + } + + expect($poryadok)->toBe(['moderate', 'resume']); +}); + +/** Повторный заход по уже созданным объявлениям на модерацию их НЕ переотправляет. */ +it('при повторном запуске не отправляет на модерацию заново', function () { + configureYandex(); + fakeYandexEndpoints(); + + $tenant = Tenant::factory()->create(); + app(AdWalletService::class)->topup($tenant->id, '20000.00', 'yandex', 'тест'); + + $campaign = makeImpressionCampaign($tenant->id, [ + 'yandex_segment_id' => 900001, + 'yandex_retargeting_list_id' => 111, + 'yandex_campaign_id' => 222, + 'yandex_ad_group_id' => 333, + ]); + seedAudience($campaign, 200); + seedBanners($campaign, ['300x250' => 4242]); + $campaign->banners()->update(['yandex_ad_id' => 999]); + + app(CampaignLauncher::class)->launch($campaign); + + Http::assertNotSent(fn ($request) => ($request->data()['method'] ?? null) === 'moderate'); +}); diff --git a/docs/superpowers/2026-07-30-HANDOFF-yandex-chto-ostalos.md b/docs/superpowers/2026-07-30-HANDOFF-yandex-chto-ostalos.md new file mode 100644 index 00000000..8f6bb771 --- /dev/null +++ b/docs/superpowers/2026-07-30-HANDOFF-yandex-chto-ostalos.md @@ -0,0 +1,128 @@ +# Промт для следующей смены — реклама Яндекса, что осталось после 30.07 + +Дата постановки: 30.07.2026, вечер. Все цифры сняты с живого боевого в этот же день. + +Кампания на бою **запущена и стоит на модерации Яндекса**. Главное дело смены — +дождаться её вердикта и довести до показов. + +--- + +## Где ты работаешь + +Рабочая папка `.claude/worktrees/telegram-merge`, ветка `fix/robot-yandex-zamok`, +совпадает с main. Хранилище — **только gitea**, GitHub мёртв. + +🟢 Сторожа работают по-настоящему: проверка ссылок, поиск утечек и статанализ +починены 30.07 и дают ноль замечаний. Пуш обычным порядком, обход просить нельзя. + +🪤 На боевом **нет истории git** — выкат туда файловый, `git log` там не работает. +Что стоит на бою, проверять наличием файлов и содержимым настроек. + +## Что уже сделано — не переделывать + +- **Аудитория дозрела:** сегмент `58283031` годен, Яндекс опознал 1644 человека из 1693. +- **Портал чинит две стены сам** (выкачено на бой 30.07): аудитория не готова → «подождите»; + смета ниже минимума площадки → понятный текст с числом показов и рублями. +- **Кампания № 6 запущена живьём.** В кабинете: кампания `713175197`, группа `5778555449`, + 15 объявлений, аудитория подключена, показ **включён**, статус **MODERATION**. +- **Кошелёк 10 000 ₽**, под кампанию заморожено 3333.36 ₽ (27778 показов до 06.08). +- **На счету Яндекса 8775 ₽** — деньги на показы есть. +- **Кабинет прибран**, временные файлы с боевого убраны, черновик кампании № 1 удалён. +- **Кнопки «Запустить» у клиента нет и не будет** — владелец решил 30.07: заявку клиент + уже отправляет, этого достаточно. Запуск дёргают оператор и робот. Вопрос закрыт. + +## Первое действие смены + +Спросить кабинет, чем кончилась модерация: + +```bash +ssh liderra-prod "cd /var/www/liderra/app && sudo -u www-data php artisan tinker /tmp/x.php" +``` + +(скрипт положить файлом: `campaigns.get` по `Ids=[713175197]`, поля `Status`, `State`, +`Statistics`). Смотреть на `Status`: + +- `ACCEPTED` — прошла. Дальше задача 1. +- `REJECTED` — отклонена. Дальше задача 2. +- `MODERATION` — ещё думают. Проверить снова позже, не дёргать кампанию. + +🪤 Судить **только по кабинету**. Наш журнал говорит, что портал ПОДУМАЛ, а не что было. + +## Задача 1. Модерация прошла — довести до показов + +1. Убедиться, что показ включён: `State` = `ON`. +2. Через сутки снять статистику: `Statistics.Impressions` больше нуля — реклама пошла. +3. Проверить, что суточное списание считает деньги: `charged_client_rub` у кампании растёт, + заморозка убывает. По истечении 06.08 остаток обязан вернуться клиенту. + +🪤 Показов будет заметно меньше сметы: частота — 1 показ на человека в 7 дней, людей 1644. +Это нормально. Незаработанные деньги возвращаются, за недоставленное клиент не платит. + +## Задача 2. Модерация отклонила — разобрать причину + +Причина приезжает в портал через окно передачи отказов. Показать её клиенту человеческим +языком, дать путь «исправить и отправить заново» (режим пересдачи уже есть — `mode:resubmit`). + +## Задача 3. Хвосты + +- **60 лишних креативов** в кабинете: всего 75, в деле 15. Владелец решил не трогать. +- Отдельного экрана «аудитория готовится» у клиента нет — понятные ответы видит тот, + кто дёргает запуск. Заводить экран не нужно, пока владелец не попросит. + +## Задача 4. Телеграм — следующая очередь + +Владелец 29.07 выбрал порядок: сначала показы Яндекса, телеграм следом. Яндекс доведён до +модерации — телеграм можно поднимать. Состояние модуля — в памяти, запись про швы и порядок +выката. + +--- + +## Что уже выяснено — не переоткрывать + +- 🔑 **Сегмент Аудиторий уходит в Директ с приставкой «20»**: `58034825` → `2058034825`. +- 🪤 **Порога «нужно 1000 опознанных» НЕ существует.** Сегмент годен и при 134. +- 🪤 **`is_processed` не значит «готов»** — это «обрабатывается». Готовность определяет + ТОЛЬКО `can_create_dependent`. +- 🔑 **Минимум Директа = 300 ₽ за каждый календарный день периода, считая оба края.** + Не постоянная цифра. Два живых отказа 30.07: период 2 дня → 600 ₽, период 8 дней → 2400 ₽. + Ставка в настройке `YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY`. +- 🔑 **Создать объявления — НЕ значит запустить рекламу.** Яндекс кладёт всё созданное + черновиком и проверку сам не начинает. Нужны `ads.moderate` (строго по `Ids`, на + `CampaignIds` отвечает «отсутствует обязательный параметр Ids») и затем + `campaigns.resume`. Порядок обязателен: черновик включить нельзя. Оба вызова врезаны + в запуск 30.07 и выкачены. +- 🔑 **Семь замечаний статанализа к боевому коду — ложные.** Разобраны 30.07, доказаны + тестами. Причина: инструмент не понимает новый способ описания типов в моделях. + +## Ловушки + +- 🪤 **Журнал — что портал ПОДУМАЛ, кабинет — что БЫЛО.** Принимать только по кабинету. +- 🪤 На боевой машине `sudo -u postgres psql` — **старая копия базы** до переезда. + Живая — Managed PG, доступ через `app/.env`. +- 🪤 Без `set_config` с номером кабинета запрос вернёт **пусто**, и это выглядит как + «данных нет». Кабинет приёмки — **2**. +- 🪤 `php artisan tinker --execute` с кавычками через ssh ломается. Класть скрипт файлом, + запускать от `www-data`, потом убирать за собой. +- 🪤 `pkill -f` по ssh **убивает собственную сессию**. Бить по номеру процесса. +- 🪤 Тестовая база общая на все ветки и отстаёт. Тесты врут «столбца нет» — прогнать + миграции для тестовой базы, потом тесты. +- 🪤 `composer pint` правит и ЧУЖИЕ давно неформатированные файлы. Перед коммитом + откатить всё, что не твоё. + +## Красные линии + +**Прод — живые клиенты и деньги.** Любой выкат, включение, удаление и движение денег — +только по явному «да» владельца. База по умолчанию **только чтение**. + +**Пуш только в gitea.** Коммит — по эскейпу, сообщение **без круглых скобок**, в файле, +явные пути. + +**Не лезть в СМС-модуль.** **Не трогать телеграмную кампанию № 713051718.** + +Владелец **не программист** — простым русским, короткими абзацами, без «ретаргетинга», +«эндпоинтов» и «деплоя». + +## Чем кончается смена + +По каждой задаче либо «сделано и проверено», либо внятное «почему не сделано и что решает +владелец». Код — с тестами, в main, обычным пушем.