diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php index 959c7d49..96a4a17d 100644 --- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php @@ -26,6 +26,7 @@ use App\Services\Advertising\CreativeJobService; use App\Services\Advertising\CreativeValidator; use App\Services\Advertising\ModerationReason; use App\Services\Advertising\YandexDirectClient; +use App\Services\Advertising\YandexMinimumSpend; use App\Support\PhoneNormalizer; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -409,6 +410,14 @@ class AdvertisingCampaignController extends Controller $payload['impressions'] = $est['impressions']; $payload['cpm_rub'] = $est['cpm_rub']; $payload['cost_rub'] = $est['cost_rub']; + + // Минимум площадки — сюда же, чтобы клиент узнал о нём В МАСТЕРЕ, а не + // английским отказом Директа после пятнадцати часов сбора аудитории. + // Считает сервер: в расчёте участвует наша наценка, и наружу из неё не уходит + // ничего, кроме показов и клиентских рублей. + $minimum = app(YandexMinimumSpend::class)->minimum((int) ($campaign->run_days ?: 30), $cpm); + $payload['min_impressions'] = $minimum['impressions']; + $payload['min_cost_rub'] = $minimum['cost_rub']; } return response()->json($payload); diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php index c572f0e7..6d403857 100644 --- a/app/app/Jobs/ChargeCampaignSpendJob.php +++ b/app/app/Jobs/ChargeCampaignSpendJob.php @@ -73,12 +73,25 @@ class ChargeCampaignSpendJob implements ShouldQueue ->whereNotNull('yandex_campaign_id') ->get(['id', 'tenant_id', 'yandex_campaign_id']); + // Кого останавливать — копим здесь и глушим ПОСЛЕ денежных транзакций. + // 🔴 Раньше stopAll() звался прямо внутри транзакции, а он ходит в Яндекс: глушит + // каждую кампанию тенанта и спрашивает у неё число показов, чтобы заплатить за них + // перед возвратом заморозки. Замок строки кампании висел всё это время — по два + // сетевых запроса на каждую кампанию. Один медленный ответ Директа запирал деньги + // клиента на минуты. + // + // Цена выноса: если процесс умрёт между списанием и остановкой, реклама доживёт до + // следующего часового обхода — он снова увидит нехватку денег и заглушит. Это + // несравнимо дешевле, чем держать денежный замок на сетевом запросе. + $ostanovit = []; + foreach ($rows as $row) { try { $delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id); $tenantId = (int) $row->tenant_id; + $neplatyozhesposoben = false; - DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void { + DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, &$neplatyozhesposoben): void { DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); // Замок строки обязателен. Идемпотентность списания держится на ключе @@ -91,10 +104,12 @@ class ChargeCampaignSpendJob implements ShouldQueue $campaign = AdCampaign::where('id', $row->id)->lockForUpdate()->firstOrFail(); $charger->charge($campaign, $delivered); - if (! $gate->isSolvent($tenantId)) { - $stopAll->stopAll($tenantId); - } + $neplatyozhesposoben = ! $gate->isSolvent($tenantId); }); + + if ($neplatyozhesposoben) { + $ostanovit[$tenantId] = true; + } } catch (Throwable $e) { Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [ 'campaign_id' => $row->id, @@ -103,6 +118,22 @@ class ChargeCampaignSpendJob implements ShouldQueue ]); } } + + foreach (array_keys($ostanovit) as $tenantId) { + try { + // Контекст тенанта нужен и здесь: на проде очередь бежит под crm_app_user, + // и без него RLS показал бы слушателю НОЛЬ кампаний — реклама осталась бы + // крутиться при пустом кошельке, молча. Ставим сессионно (третий аргумент + // false), а не SET LOCAL: своей транзакции тут намеренно нет. + DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenantId]); + $stopAll->stopAll($tenantId); + } catch (Throwable $e) { + Log::warning('ChargeCampaignSpendJob: сбой остановки рекламы тенанта', [ + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + ]); + } + } } /** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */ diff --git a/app/app/Listeners/PauseCampaignsOnAdStop.php b/app/app/Listeners/PauseCampaignsOnAdStop.php index 89898e3f..399b4604 100644 --- a/app/app/Listeners/PauseCampaignsOnAdStop.php +++ b/app/app/Listeners/PauseCampaignsOnAdStop.php @@ -7,13 +7,17 @@ namespace App\Listeners; use App\Events\AdvertisingStopped; use App\Models\AdCampaign; use App\Services\Advertising\AdWalletService; +use App\Services\Advertising\CampaignImpressionCharger; use App\Services\Advertising\YandexDirectClient; use Illuminate\Support\Facades\Log; use Throwable; final class PauseCampaignsOnAdStop { - public function __construct(private readonly AdWalletService $wallet) {} + public function __construct( + private readonly AdWalletService $wallet, + private readonly CampaignImpressionCharger $charger, + ) {} public function handle(AdvertisingStopped $event): void { @@ -40,8 +44,43 @@ final class PauseCampaignsOnAdStop Log::warning('PauseCampaignsOnAdStop suspend failed: '.$e->getMessage(), ['campaign' => $campaign->id]); } } + + // 🔴 Сперва платим за уже показанное, и только потом отпускаем деньги. + // + // Списание за показы делает часовая задача, и она берёт ТОЛЬКО кампании со + // статусом «крутится». Остановленную она пропускает НАВСЕГДА: до часа показов + // уходило клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили + // в паузе (§6и отчёта приёмки 04.08.2026), только через другую дверь. + // + // Лишнего обращения в Директ это не стоит: мы и так только что ходили туда + // глушить рекламу. + $uznaliPokazy = true; + if ($direct !== null && $campaign->yandex_campaign_id !== null) { + try { + $this->charger->charge($campaign, $direct->getCampaignImpressions((int) $campaign->yandex_campaign_id)); + } catch (Throwable $e) { + $uznaliPokazy = false; + Log::warning('PauseCampaignsOnAdStop charge failed: '.$e->getMessage(), ['campaign' => $campaign->id]); + } + } + + $campaign->refresh(); + + // Списание могло само закрыть кампанию — смета откручена. Тогда она уже в + // «Показы откручены», остаток заморозки вернулся (ВЫХОД 1), и статуса + // «нет денег» поверх не ставим: пятого выхода снятия заморозки не появляется. + if ($campaign->status === AdCampaign::STATUS_COMPLETED) { + continue; + } + $campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]); + // Не узнали число показов — рекламу заглушили, но деньги НЕ отпускаем: + // отпустить их, не заплатив за показы, значит подарить их. + if (! $uznaliPokazy) { + continue; + } + // ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на // мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту // распорядиться пополнением (в т.ч. перезапустить рекламу). diff --git a/app/app/Services/Advertising/CampaignLauncher.php b/app/app/Services/Advertising/CampaignLauncher.php index 83a44fef..eafd4be1 100644 --- a/app/app/Services/Advertising/CampaignLauncher.php +++ b/app/app/Services/Advertising/CampaignLauncher.php @@ -36,6 +36,7 @@ final class CampaignLauncher private readonly CampaignAudienceBuilder $audience, private readonly AdWalletService $wallet, private readonly AdImpressionPricing $pricing, + private readonly YandexMinimumSpend $minimum, ) {} /** @@ -263,22 +264,15 @@ final class CampaignLauncher // // Проверяем ЗДЕСЬ — до первого обращения к Яндексу: в кабинете ничего не создаётся, // деньги не морозятся, кампания остаётся черновиком. - $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, - ); + // + // 🔑 Счёт минимума живёт в YandexMinimumSpend и ОДИН на портал: этим же счётчиком + // мастер предупреждает клиента заранее. Две копии одного расчёта однажды разойдутся, + // и клиент увидит в мастере одно число, а в отказе Директа — другое. + $dneyVPeriode = (int) CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1; + $minimum = $this->minimum->minimumForDays($dneyVPeriode, $clientCpm); $spendLimitRub = bcdiv((string) $spendLimitMicros, '1000000', 2); - if (bccomp($spendLimitRub, $minSpendRub, 2) < 0) { - // Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума. - $rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8); - $minImpressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6)); - - throw new BudgetBelowYandexMinimumException( - $minImpressions, - $this->pricing->clientCostRub($minImpressions, $clientCpm), - ); + if (bccomp($spendLimitRub, $minimum['spend_rub'], 2) < 0) { + throw new BudgetBelowYandexMinimumException($minimum['impressions'], $minimum['cost_rub']); } $freqImpr = (int) ($campaign->frequency ?? 1); diff --git a/app/app/Services/Advertising/YandexMinimumSpend.php b/app/app/Services/Advertising/YandexMinimumSpend.php new file mode 100644 index 00000000..d2bdc7bc --- /dev/null +++ b/app/app/Services/Advertising/YandexMinimumSpend.php @@ -0,0 +1,87 @@ +minimumForDays($this->dneyVPeriode($runDays), $clientCpmRub); + } + + /** + * То же, но когда длина периода уже известна точно (запуск считает её по датам, + * которые уходят в Директ, и переиспользует сохранённую дату конца). + * + * @return array{impressions:int, cost_rub:string, days:int, spend_rub:string} + */ + public function minimumForDays(int $dney, string $clientCpmRub): array + { + $dney = max(1, $dney); + + $margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00'); + $share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ + $yandexCpm = bcmul($clientCpmRub, $share, 2); // ₽ за 1000 для Яндекса + $guard = (string) config('services.yandex_direct.spend_limit_guard_multiplier'); + + $minSpendRub = bcmul( + (string) config('services.yandex_direct.min_spend_limit_rub_per_day'), + (string) $dney, + 2, + ); + + // Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума. + $rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8); + $impressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6)); + + return [ + 'impressions' => $impressions, + 'cost_rub' => $this->pricing->clientCostRub($impressions, $clientCpmRub), + 'days' => $dney, + 'spend_rub' => $minSpendRub, + ]; + } + + /** + * Длина периода в календарных днях — ровно так, как её считает запуск: от старта до + * конца ВКЛЮЧИТЕЛЬНО. Срок в один день означает период «сегодня…завтра», то есть два + * дня по календарю Яндекса, а значит и минимум двойной. + */ + public function dneyVPeriode(int $runDays): int + { + $start = CarbonImmutable::now()->startOfDay(); + $end = $start->addDays(max($runDays - 1, 1)); + + return (int) $start->diffInDays($end) + 1; + } +} diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts index 6f7f4367..aa7e8e3e 100644 --- a/app/resources/js/api/advertising.ts +++ b/app/resources/js/api/advertising.ts @@ -106,6 +106,14 @@ export interface AudienceSize { impressions?: number; cpm_rub?: string; cost_rub?: string; + /** + * Минимум площадки: Директ не берёт кампанию, у которой расход за период ниже его + * минимума (300 ₽ за каждый календарный день). Считает сервер — в расчёте участвует + * наша наценка, и клиенту её видеть не положено; сюда приходят только показы и + * клиентские рубли. Живой отказ 30.07.2026 клиент видел по-английски и на последнем шаге. + */ + min_impressions?: number; + min_cost_rub?: string; } /** Тело POST/PATCH /api/advertising/campaigns (создание/правка черновика кампании «за показы»). */ diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue index 23f13673..e6f5a572 100644 --- a/app/resources/js/components/advertising/CampaignWizard.vue +++ b/app/resources/js/components/advertising/CampaignWizard.vue @@ -98,6 +98,22 @@ const canSubmit = computed(() => audienceSize.value?.enough ?? false); /** Клиентская цена за 1000 показов — берём из ответа сервера (cpm_rub), НЕ хардкодим. */ const cpmLabel = computed(() => `${formatPlain(Number(audienceSize.value?.cpm_rub ?? 120))} за 1000 показов`); +/** + * Смета не дотягивает до нижнего порога площадки. + * + * 🔴 Директ отказывает по-английски и на последнем шаге — после того как клиент собрал + * аудиторию (пятнадцать часов) и залил пятнадцать картинок. Порог считает сервер (в + * расчёте наша наценка), сюда приходят только показы и клиентские рубли. + */ +const nizheMinimuma = computed(() => { + const est = audienceSize.value; + if (audienceLoading.value || !est || est.impressions == null || est.min_impressions == null) { + return false; + } + + return est.impressions < est.min_impressions; +}); + /** Человекочитаемая подпись режима сбора аудитории — для сводки шага 4. */ const modeLabel = computed(() => (form.mode === 'auto' ? 'Крутить постоянно' : 'Разовый запуск')); @@ -854,9 +870,16 @@ defineExpose({ Показов - + + - + @@ -871,6 +894,31 @@ defineExpose({ +

+ Это потолок, а не обещание. Реклама упрётся не в деньги, а в ваш + список: Яндекс покажет её ровно тем людям, кого сумеет узнать в + интернете, и только когда они туда зайдут. За показы, которых не + случится, деньги вернутся на счёт. +

+ + + Столько показов Яндекс не возьмёт: у него есть нижний порог на такой + срок. Самая маленькая кампания здесь — {{ audienceSize?.min_impressions }} + показов за {{ formatPlain(Number(audienceSize?.min_cost_rub ?? 0)) }}. + Поднимите частоту показов или укоротите срок. + +

{{ frequencyError }}

@@ -1047,7 +1095,10 @@ defineExpose({ Показов - ~{{ audienceSize?.impressions ?? 0 }} + + + не больше {{ audienceSize?.impressions ?? 0 }} + Цена diff --git a/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php b/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php new file mode 100644 index 00000000..6dfd191b --- /dev/null +++ b/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php @@ -0,0 +1,100 @@ +tenant = Tenant::factory()->create(); + config(['services.yandex_direct.enabled' => true]); + config(['services.yandex_direct.token' => 'DIRTOKEN']); + config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']); +}); + +it('остановка без денег сперва платит за показанное, потом отпускает заморозку', function () { + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 777]]]]), + '*/json/v5/reports' => Http::response('2500', 200), // накопительно за всё время + ]); + + $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, + 'yandex_campaign_id' => 777, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '0.00', + ]); + $svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + event(new AdvertisingStopped($this->tenant->id)); + + $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); + + // 2500 показов × 120 ₽ за тысячу = 300 ₽ — они обязаны уйти с баланса. + expect((string) $campaign->fresh()->charged_client_rub)->toBe('300.00') + ->and((string) $wallet->balance_rub)->toBe('2700.00') + ->and((string) $wallet->frozen_rub)->toBe('0.00') + ->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS); +}); + +it('не узнали число показов — рекламу глушим, но деньги НЕ отпускаем', function () { + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 778]]]]), + '*/json/v5/reports' => Http::response(['error' => ['error_detail' => 'нет доступа']], 400), + ]); + + $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, + 'yandex_campaign_id' => 778, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '0.00', + ]); + $svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + event(new AdvertisingStopped($this->tenant->id)); + + $wallet = AdWallet::where('tenant_id', $this->tenant->id)->first(); + + // Реклама заглушена — статус сменился. + expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS) + // Но заморозка на месте: отпустить, не заплатив, значит подарить показы. + ->and((string) $wallet->frozen_rub)->toBe('1200.00') + ->and((string) $campaign->fresh()->charged_client_rub)->toBe('0.00'); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php b/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php new file mode 100644 index 00000000..fbd96413 --- /dev/null +++ b/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php @@ -0,0 +1,84 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + $this->actingAs($this->user); + + config(['services.yandex_direct.min_spend_limit_rub_per_day' => 300]); + config(['services.yandex_direct.spend_limit_guard_multiplier' => '1.2']); + DB::table('ad_settings')->update(['ad_margin_percent' => '40.00']); +}); + +it('мастер получает минимум площадки в показах и рублях, наценку не раскрывая', function () { + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Черновик', + 'status' => AdCampaign::STATUS_DRAFT, + 'audience_days' => 10, + 'mode' => AdCampaign::MODE_AUTO, + 'run_days' => 1, + 'client_cpm_rub' => '500.00', + ]); + + $otvet = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?frequency=5&days=10") + ->assertOk(); + + // Срок 1 день → период сегодня…завтра = 2 календарных дня → минимум Яндексу 600 ₽. + // Яндексу уходит 60 % от 500 ₽ = 300 ₽ за тысячу; с запасом 1,2 это 0,36 ₽ за показ. + // 600 / 0,36 = 1 666,67 → 1 667 показов. Клиенту это 1667 × 500 / 1000 = 833,50 ₽. + $otvet->assertJsonPath('min_impressions', 1667) + ->assertJsonPath('min_cost_rub', '833.50'); + + // Наценка, доля Яндекса и его ставка клиенту не видны ни в каком виде. + $telo = $otvet->getContent(); + expect($telo)->not->toContain('margin') + ->and($telo)->not->toContain('yandex_cpm') + ->and($telo)->not->toContain('300.00'); +}); + +it('запуск и мастер считают минимум ОДНИМ счётчиком', function () { + $schetchik = app(\App\Services\Advertising\YandexMinimumSpend::class); + + $itog = $schetchik->minimum(1, '500.00'); + + expect($itog['impressions'])->toBe(1667) + ->and($itog['cost_rub'])->toBe('833.50'); +}); + +it('длиннее срок — выше минимум: неделя стоит семь дней по 300 ₽', function () { + $schetchik = app(\App\Services\Advertising\YandexMinimumSpend::class); + + // Срок 7 дней → период сегодня…+6 = 7 календарных дней → 2100 ₽ Яндексу. + // 2100 / 0,36 = 5 833,33 → 5 834 показа → 2 917,00 ₽ клиенту. + $itog = $schetchik->minimum(7, '500.00'); + + expect($itog['impressions'])->toBe(5834) + ->and($itog['cost_rub'])->toBe('2917.00'); +}); diff --git a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php index 9d01ae10..aab8d213 100644 --- a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php +++ b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php @@ -244,6 +244,11 @@ it('ВЫХОД 3: остановка без средств снимает зам ]); Http::fake([ '*/json/v5/campaigns' => Http::response(['result' => []], 200), + // 🔴 С 04.08.2026 остановка сперва платит за уже показанное и только потом + // отпускает заморозку (AdStopNoFundsChargesFirstTest). Значит миру теста нужен + // ответ Директа про показы: без него портал НАМЕРЕННО не отпустит деньги — + // отпустить, не заплатив, значит подарить показы. Ноль показов = платить не за что. + '*/json/v5/reports' => Http::response('0', 200), ]); $tenant = Tenant::factory()->create(); diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php index e6f2c231..9fb813e4 100644 --- a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php +++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php @@ -145,6 +145,42 @@ it('stops all advertising when the charge leaves the tenant insolvent', function Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id); }); +/** + * 🔴 Остановка «нет денег» ходит в Яндекс: глушит каждую кампанию и спрашивает у неё + * число показов, чтобы заплатить за них перед возвратом заморозки. Пока это делалось + * ВНУТРИ денежной транзакции, замок строки кампании держался всё время сетевых + * запросов — по два на каждую кампанию тенанта. Один зависший запрос к Директу + * запирал деньги клиента на минуты и тормозил всё, что к ним обращается. + * + * Сторож смотрит на глубину транзакции в момент остановки: она обязана совпадать с + * той, что была до запуска задачи, — то есть своей транзакции задача здесь не держит. + */ +it('останавливает рекламу ВНЕ денежной транзакции — замок не висит на сетевом запросе', function () { + configureYandexDirectForSpend(); + Http::fake([ + '*/json/v5/reports' => Http::response('2500', 200), + '*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 222]]]]), + ]); + + $tenant = Tenant::factory()->create(); + // 400 ₽ на счету, 200 ₽ заперты под ЧУЖУЮ кампанию; списание 300 ₽ уводит + // свободный остаток в минус → стоп всей рекламы тенанта. + 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'); + + $urovenVMomentOstanovki = null; + Event::listen(AdvertisingStopped::class, function () use (&$urovenVMomentOstanovki): void { + $urovenVMomentOstanovki = DB::transactionLevel(); + }); + + $urovenDo = DB::transactionLevel(); + app(ChargeCampaignSpendJob::class)->handle(); + + expect($urovenVMomentOstanovki)->not->toBeNull() + ->and($urovenVMomentOstanovki)->toBe($urovenDo); +}); + it('does nothing and sends no HTTP when the yandex_direct switch is off', function () { config(['services.yandex_direct.enabled' => false]); fakeYandexImpressionsReport(); diff --git a/app/tests/Frontend/advertising-campaign-wizard-chestnoe-obeshchanie.spec.ts b/app/tests/Frontend/advertising-campaign-wizard-chestnoe-obeshchanie.spec.ts new file mode 100644 index 00000000..8f5f5742 --- /dev/null +++ b/app/tests/Frontend/advertising-campaign-wizard-chestnoe-obeshchanie.spec.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; + +/** + * Мастер обещал показы как ФАКТ: «~8 150». Клиент читал это как «столько будет». + * + * 🔴 Живой замер 04.08.2026 по кабинету Директа: чужая медийная кампания с бюджетом + * 13 000 ₽ за четверо суток набрала 457 показов и потратила 138 ₽. Деньги не кончились — + * кончились ЛЮДИ: Яндекс показывает ровно стольким, скольких из загруженного списка он + * сумел найти в интернете, и не чаще, чем эти люди туда заходят. + * + * То есть число из мастера — это ПОТОЛОК, до которого кампания почти наверняка не + * дотянет, а не обещание. Клиент узнавал правду не в мастере, а через сутки после + * запуска, когда деньги уже заморожены. + * + * Отсюда два сторожа: число подаётся словами «не больше», и рядом сказано, обо что + * упрётся и что будет с неизрасходованными деньгами. + */ + +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, +})); + +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: 'Реклама от 04.08.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: '500.00', + cost_rub: '4075.00', +}; + +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 }; +} + +const BANNERS_EMPTY = { + approved_at: null, + max_bytes: 153600, + formats: ['jpg', 'jpeg', 'png', 'gif'], + slots: BANNER_SIZES.map(([w, h]) => emptySlot(w, h)), +}; + +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); +} + +describe('CampaignWizard.vue — честное обещание показов', () => { + 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('число показов подаётся как потолок «не больше», а не как обещание', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + const stroka = w.find('[data-testid="pokazy-potolok"]'); + expect(stroka.exists()).toBe(true); + expect(stroka.text()).toContain('не больше'); + expect(stroka.text()).toContain('8150'); + }); + + it('рядом сказано, обо что упрётся и что будет с неизрасходованными деньгами', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + const poyasnenie = w.find('[data-testid="pokazy-potolok-poyasnenie"]'); + expect(poyasnenie.exists()).toBe(true); + + const tekst = poyasnenie.text(); + // Обо что упрётся — про список людей, а не про деньги. + expect(tekst).toContain('список'); + // Что с деньгами за непоказанное. + expect(tekst).toContain('вернутся'); + }); + + it('в сводке перед отправкой число показов тоже названо потолком', async () => { + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + // Шаг 3 (баннеры) в этой проверке не участвует — доводим до сводки напрямую. + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + (w.vm as unknown as { bannersApprovedAt: string | null }).bannersApprovedAt = '2026-08-04T00:00:00Z'; + (w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/'; + await w.vm.$nextTick(); + await w.find('[data-testid="wizard-next"]').trigger('click'); + await flush(); + await w.vm.$nextTick(); + + const svodka = w.find('[data-testid="svodka-pokazy"]'); + expect(svodka.exists()).toBe(true); + expect(svodka.text()).toContain('не больше'); + expect(svodka.text()).toContain('8150'); + }); + + /** + * 🔴 Живой отказ 30.07.2026: Директ не берёт кампанию дешевле своего минимума и пишет + * об этом по-английски — «Budget for this period cannot be less than 600 rub.» — + * на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию и залил + * пятнадцать картинок. Предупредить надо В МАСТЕРЕ и человеческим языком. + */ + it('смета ниже минимума площадки — мастер предупреждает и говорит, что делать', async () => { + fetchAudienceSizeMock.mockResolvedValue({ + ...ESTIMATE, + frequency: 1, + impressions: 500, + cost_rub: '250.00', + min_impressions: 1667, + min_cost_rub: '833.50', + }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + const preduprezhdenie = w.find('[data-testid="minimum-ploshchadki"]'); + expect(preduprezhdenie.exists()).toBe(true); + + const tekst = preduprezhdenie.text(); + expect(tekst).toContain('1667'); + expect(tekst).toContain('833'); + // Английского отказа Директа клиент видеть не должен. + expect(w.text()).not.toContain('Budget for this period'); + }); + + it('смета выше минимума — предупреждения нет', async () => { + fetchAudienceSizeMock.mockResolvedValue({ + ...ESTIMATE, + min_impressions: 1667, + min_cost_rub: '833.50', + }); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await gotoStep2(w); + + expect(w.find('[data-testid="minimum-ploshchadki"]').exists()).toBe(false); + }); + + it('слово «потолок» не появляется, пока смета ещё считается', async () => { + let otdat: (v: unknown) => void = () => {}; + fetchAudienceSizeMock.mockReturnValue(new Promise((r) => { otdat = r; })); + + const w = mount(CampaignWizard, { global: { plugins: [vuetify] } }); + await flush(); + await w.vm.$nextTick(); + await w.find('[data-testid="wizard-next"]').trigger('click'); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="pokazy-potolok-poyasnenie"]').exists()).toBe(false); + + otdat(ESTIMATE); + await flush(); + await w.vm.$nextTick(); + + expect(w.find('[data-testid="pokazy-potolok-poyasnenie"]').exists()).toBe(true); + }); +});