80987996ed
Три починки, каждая сперва увидена красной. 1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету: чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера это потолок, до которого кампания почти наверняка не дотянет. Стало «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги; за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты и в сводке перед отправкой. 2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное. Списание делает часовая задача, а она берёт только кампании со статусом «крутится» — остановленную пропускала навсегда. Показы последнего часа уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай; не узнал число показов — не отпускай вовсе. 3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов. Вынесено наружу: денежная операция закрывается, и только потом остановка. Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб за каждый календарный день и отвечает по-английски на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал: ею пользуются и запуск, и мастер — две копии однажды разошлись бы. Сторожа: 7 новых на бэкенде и фронте, каждый принят красным. Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
85 lines
4.3 KiB
PHP
85 lines
4.3 KiB
PHP
<?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');
|
||
});
|