fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное

Три починки, каждая сперва увидена красной.

1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету:
   чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов
   и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера
   это потолок, до которого кампания почти наверняка не дотянет. Стало
   «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги;
   за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты
   и в сводке перед отправкой.

2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное.
   Списание делает часовая задача, а она берёт только кампании со статусом
   «крутится» — остановленную пропускала навсегда. Показы последнего часа
   уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
   в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай;
   не узнал число показов — не отпускай вовсе.

3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это
   ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов.
   Вынесено наружу: денежная операция закрывается, и только потом остановка.

Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб
за каждый календарный день и отвечает по-английски на последнем шаге, когда
клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает
заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал:
ею пользуются и запуск, и мастер — две копии однажды разошлись бы.

Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
This commit is contained in:
Дмитрий
2026-08-05 04:26:27 +03:00
parent d1e6fcab7f
commit 80987996ed
12 changed files with 723 additions and 23 deletions
@@ -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);
+35 -4
View File
@@ -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). */
+40 -1
View File
@@ -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 — реклама заглушена из-за нехватки денег. Держать заморозку на
// мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту
// распорядиться пополнением (в т.ч. перезапустить рекламу).
@@ -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);
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Services\Advertising;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
/**
* Минимум площадки: сколько показов нужно заказать, чтобы Директ вообще взял кампанию.
*
* 🔴 Живой отказ 30.07.2026: «Budget for this period cannot be less than 600 rub.»
* по-английски, на последнем шаге, после пятнадцати часов сбора аудитории и пятнадцати
* загруженных картинок. Минимум НЕ постоянный: он считается от длины периода 300 за
* каждый календарный день, считая оба края. Первый отказ приняли за постоянные 600 и
* тут же напоролись на второй: та же кампания на неделю потребовала 2400 .
*
* Счётчик ОДИН на портал им пользуются и запуск (чтобы не лезть в Яндекс заведомо
* зря), и мастер (чтобы предупредить клиента заранее). Две копии одного расчёта однажды
* разойдутся, и верить будет нечему.
*
* Считать это в браузере нельзя: в расчёте участвует наша наценка, а клиенту её видеть
* не положено. Поэтому наружу уходят только показы и клиентские рубли.
*
* MONEY: только bcmath.
*/
final class YandexMinimumSpend
{
public function __construct(private readonly AdImpressionPricing $pricing) {}
/**
* Минимум для кампании, которая будет крутиться `runDays` дней.
*
* @return array{impressions:int, cost_rub:string, days:int}
*/
public function minimum(int $runDays, string $clientCpmRub): array
{
return $this->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;
}
}
+8
View File
@@ -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 (создание/правка черновика кампании «за показы»). */
@@ -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({
<v-list density="comfortable" class="mb-2 border rounded" data-testid="frequency-estimate">
<v-list-item prepend-icon="mdi-eye-outline">
<v-list-item-title>Показов</v-list-item-title>
<v-list-item-subtitle class="num">
<!--
🔴 Это ПОТОЛОК, а не обещание. Живой замер 04.08.2026: чужая
медийная кампания с бюджетом 13 000 за четверо суток набрала
457 показов и потратила 138 деньги не кончились, кончились
люди. Пока здесь стояло «~8150», клиент читал это как «столько
будет» и узнавал правду через сутки после заморозки денег.
-->
<v-list-item-subtitle class="num" data-testid="pokazy-potolok">
<v-progress-circular v-if="audienceLoading" indeterminate size="16" width="2" color="primary" />
<template v-else>~{{ audienceSize?.impressions ?? 0 }}</template>
<template v-else>не больше {{ audienceSize?.impressions ?? 0 }}</template>
</v-list-item-subtitle>
</v-list-item>
<v-list-item prepend-icon="mdi-tag-outline">
@@ -871,6 +894,31 @@ defineExpose({
</v-list-item>
</v-list>
<p
v-if="audienceSize && !audienceLoading"
class="text-body-2 text-medium-emphasis mb-2"
data-testid="pokazy-potolok-poyasnenie"
>
Это потолок, а не обещание. Реклама упрётся не в деньги, а в ваш
список: Яндекс покажет её ровно тем людям, кого сумеет узнать в
интернете, и только когда они туда зайдут. За показы, которых не
случится, деньги вернутся на счёт.
</p>
<v-alert
v-if="nizheMinimuma"
type="warning"
variant="tonal"
density="comfortable"
class="mb-3"
data-testid="minimum-ploshchadki"
>
Столько показов Яндекс не возьмёт: у него есть нижний порог на такой
срок. Самая маленькая кампания здесь {{ audienceSize?.min_impressions }}
показов за {{ formatPlain(Number(audienceSize?.min_cost_rub ?? 0)) }}.
Поднимите частоту показов или укоротите срок.
</v-alert>
<p v-if="frequencyError" class="text-body-2 text-error" data-testid="wizard-frequency-error">
{{ frequencyError }}
</p>
@@ -1047,7 +1095,10 @@ defineExpose({
</v-list-item>
<v-list-item prepend-icon="mdi-eye-outline">
<v-list-item-title>Показов</v-list-item-title>
<v-list-item-subtitle class="num">~{{ audienceSize?.impressions ?? 0 }}</v-list-item-subtitle>
<!-- Тот же потолок, что и на шаге 2 — сводка не должна обещать больше. -->
<v-list-item-subtitle class="num" data-testid="svodka-pokazy">
не больше {{ audienceSize?.impressions ?? 0 }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item prepend-icon="mdi-tag-outline">
<v-list-item-title>Цена</v-list-item-title>
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Events\AdvertisingStopped;
use App\Models\AdCampaign;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
// Откат после теста. Без него записи файла остаются в базе и роняют ЧУЖИЕ тесты.
uses(DatabaseTransactions::class);
/**
* ВЫХОД 3 «Остановлено, нет денег».
*
* 🔴 Дыра, найденная 04.08.2026 рядом с той же дырой в паузе (§6и отчёта приёмки).
* Когда у клиента кончались деньги, портал глушил рекламу в Яндексе, ставил статус
* «Остановлено, нет денег» и БЕЗУСЛОВНО возвращал заморозку не заплатив за показы,
* которые уже случились. Списание за показы делает часовая задача, и она берёт ТОЛЬКО
* кампании со статусом «крутится»: остановленную она пропускает навсегда.
*
* Итог: показы последнего часа уходили клиенту даром, а Яндексу за них платили мы.
* Ровно то же, что чинили в паузе, только через другую дверь.
*
* Правило то же: СПЕРВА заплати за показанное, ПОТОМ отпускай деньги. Не смог узнать
* число показов деньги не отпускай вовсе: отпустить, не заплатив, значит подарить.
*/
beforeEach(function () {
$this->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');
});
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
/**
* Минимум площадки в мастере, а не на последнем шаге.
*
* 🔴 Живой отказ 30.07.2026: Директ не берёт кампанию, у которой расход за период ниже
* его минимума, и отвечает по-английски «Budget for this period cannot be less than
* 600 rub.». Клиент видел этот текст ПОСЛЕ того, как собрал аудиторию (15 часов),
* залил пятнадцать картинок и нажал «Запустить». Понять из него, чего от него хотят,
* невозможно.
*
* Минимум считается от длины периода 300 за каждый календарный день. Перевести его
* в понятные клиенту величины (сколько показов и сколько рублей) можно только на сервере:
* в расчёте участвует наша наценка, а её клиенту показывать нельзя.
*
* 🔑 Формула ОДНА на портал: и запуск, и мастер спрашивают один и тот же счётчик.
* Две копии одного расчёта однажды разойдутся, и верить будет нечему.
*/
beforeEach(function () {
$this->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');
});
@@ -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();
@@ -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();
@@ -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<void> {
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<typeof mount>): Promise<void> {
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);
});
});