fix реклама: минимум площадки считается по длине периода, а созданные объявления сами уходят на модерацию

Живой запуск кампании на бою 30.07.2026 вскрыл две поломки, которых вчерашняя
починка не видела. Обе — из класса молчаливых: портал рапортует успех, а в жизни
ничего не происходит.

ПЕРВАЯ. Минимум Директа НЕ постоянный.

Вчера мы приняли живой отказ «Budget for this period cannot be less than 600 rub.»
за постоянный порог и зашили 600 ₽ в настройку. Сегодня та же кампания на неделю
получила отказ «cannot be less than 2400 rub.» — проверка её пропустила, и клиент
снова увидел английский текст.

Две точки дали правило: минимум = 300 ₽ за каждый календарный день периода,
считая оба края.
   период 30.07-31.07, 2 дня → 600 ₽  = 2 × 300
   период 30.07-06.08, 8 дней → 2400 ₽ = 8 × 300

Теперь порог считается от периода показа, а ставка за день вынесена в настройку
YANDEX_DIRECT_MIN_SPEND_RUB_PER_DAY. Период вычисляется ДО денег — иначе считать
минимум не от чего.

ВТОРАЯ. Создать объявления — не значит запустить рекламу.

Запуск проходил успешно, портал ставил статус «на модерации», а в кабинете лежали
кампания, группа и 15 объявлений в состоянии DRAFT и OFF. Яндекс кладёт всё
созданное черновиком и проверку сам не начинает. Реклама не показалась бы никогда,
и узнать об этом можно было только глазами в кабинете: наш журнал говорил, что всё
хорошо.

Добавлены два вызова, которых не было вовсе:
   ads.moderate     — отдать созданные объявления на проверку
   campaigns.resume — включить показ

Порядок обязателен. Пока кампания черновик, включить её нельзя — Директ отвечает
«кампания является черновиком и не может быть остановлена». Сначала модерация,
она переводит кампанию в MODERATION, и только потом включение.

Отбор объявлений строго по Ids. На CampaignIds Директ отвечает «отсутствует
обязательный параметр Ids» — именно так отправка молча не сработала бы.

На модерацию уходят объявления, созданные ИМЕННО этим заходом: повторная отправка
уже проверяемого — отказ по позиции, он порвал бы возобновляемый запуск. Включение
показа безобидно при любом повторе: на включённой кампании Яндекс отвечает
предупреждением, а не отказом.

Заодно клиент Директа перестал молчать про отказы внутри ответа: раньше он смотрел
только AddResults, UpdateResults и DeleteResults, теперь ещё ModerateResults и
ResumeResults. Отказ по позиции в этих двух проходил бы насквозь незамеченным.

ПРОВЕРЕНО ЖИВЬЁМ. Кампания № 6 на бою: в кабинете 713175197, группа 5778555449,
15 объявлений, аудитория подключена, статус MODERATION, показ включён. На счету
Яндекса 8775 ₽. У клиента заморожено 3333.36 ₽ за 27778 показов до 06.08.

Тесты: 346 зелёных по рекламе, 43 по клиенту Директа, статанализ ноль замечаний.
Четыре новых теста — минимум по длине периода на боевом случае, отправка на
модерацию, порядок модерация-до-включения, отсутствие повторной отправки.
Два старых теста закрепляли частный случай в два дня — им проставлен явный срок
показа, иначе они молча описывали бы неправду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-30 16:43:25 +03:00
parent 3cb4e08390
commit 9df7846fd6
6 changed files with 344 additions and 17 deletions
@@ -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);
@@ -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<int> $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(
+8 -3
View File
@@ -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.0731.07 (2 дня) → «cannot be less than 600 rub.» = 2 × 300
// период 30.0706.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). Программный доступ
@@ -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',
]);
@@ -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');
});
@@ -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, обычным пушем.