Files
portal/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php
T
Дмитрий 80987996ed fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное
Три починки, каждая сперва увидена красной.

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

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

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

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

Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
2026-08-05 04:26:27 +03:00

85 lines
4.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
});