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:
@@ -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(
|
||||
|
||||
@@ -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). Программный доступ
|
||||
|
||||
@@ -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, обычным пушем.
|
||||
Reference in New Issue
Block a user