Files
portal/app/tests/Feature/Advertising/CampaignImpressionChargerTest.php
T
Дмитрий d26716ed90 fix реклама за показы: срок показа закрывает кампанию, отчёт робота только по заданию в работе, гонка выдачи заданий
Р1. Кампания, не добравшая смету показов, оставалась running навсегда, а заморозка
денег клиента - ACTIVE навсегда: единственным переходом в completed было
delivered >= paid_impressions, а задачи, закрывающей кампанию по истечении срока
показа, не существовало вовсе. Для медийки по списку телефонов недокрут сметы -
типовой исход, а не редкий случай.

Новая колонка ad_campaigns.shows_until хранит последний день показа - ровно тот,
что уходит в Директ параметром EndDate. Пишется вместе с yandex_campaign_id, то
есть в момент, когда дату начинает держать Яндекс; возобновляемый запуск
переиспользует уже записанную дату, чтобы портал и Директ считали срок одинаково.
У выхода 1 в CampaignImpressionCharger появилось второе условие - новых мест
вызова AdWalletService::release не добавилось, их по-прежнему ровно четыре.
Запись v9.07 в журнале схемы, rls-reviewer GO.

Р2. Отчёт робота принимался по любому заданию в любом состоянии: номер брался из
адреса как есть. Готово по чужому ещё не выданному заданию разложило бы номера
креативов чужой кампании по её баннерам - картинка одного клиента уехала бы в
объявление другого; сбой по уже закрытому заданию переписал бы правильный
результат на failed. Теперь done принимает отчёт только по заданию в статусе
taken - 409 в остальных случаях, та же проверка продублирована в сервисе.

Р3, первая половина. Проверка «в работе никого» в takeNext не блокировала строку:
две одновременные выдачи обе её проходили и уносили разные задания. Слепки
creatives.get перемешивались, а размеры блоков у всех клиентов одинаковые, поэтому
итог - тихая привязка чужого номера креатива. Гарантию даёт частичный уникальный
индекс uq_creative_job_single_taken, плюс advisory-замок первой строкой транзакции,
чтобы штатный путь спокойно отвечал роботу «работы нет».

Осталось по Р3: привязать выдачу файла к номеру задания, rls-reviewer по индексу,
запись v9.08 в журнал схемы. Ход работы - в файле PROGRESS рядом с промтом v12.

Тесты, прогнаны в одиночку: портал 256/256, робот 35/35. Все девять новых тестов
были красными до правок, каждая защита проверена вырезанием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:36:03 +03:00

234 lines
10 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\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignImpressionCharger;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function makePokazyCampaign(int $tenantId, ?int $paidImpressions, ?string $clientCpmRub = null): AdCampaign
{
return AdCampaign::create([
'tenant_id' => $tenantId,
'name' => 'Кампания за показы',
'status' => AdCampaign::STATUS_RUNNING,
'audience_days' => 30,
'use_uploaded_list' => true,
'frequency' => 15,
'estimated_impressions' => $paidImpressions,
'paid_impressions' => $paidImpressions,
'client_cpm_rub' => $clientCpmRub,
]);
}
it('charges the client for actually delivered impressions', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('300.00')
->and($campaign->delivered_impressions)->toBe(2500)
->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9700.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('does not double-charge when called again with the same delivered count', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$charger = app(CampaignImpressionCharger::class);
$charger->charge($campaign, 2500);
$charger->charge($campaign->refresh(), 2500);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('300.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('charges only the delta on a later top-up call', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$charger = app(CampaignImpressionCharger::class);
$charger->charge($campaign, 2500);
$charger->charge($campaign->refresh(), 6000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('720.00')
->and($campaign->delivered_impressions)->toBe(6000);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9280.00'); // 10000 720
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(2);
});
it('marks the campaign completed once delivered reaches the paid amount', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 5000);
app(CampaignImpressionCharger::class)->charge($campaign, 5000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('600.00')
->and($campaign->delivered_impressions)->toBe(5000)
->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED);
});
it('caps billable impressions at the paid amount when delivered overshoots', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 5000);
app(CampaignImpressionCharger::class)->charge($campaign, 8000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('600.00')
->and($campaign->delivered_impressions)->toBe(8000)
->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9400.00'); // 10000 600
});
it('charges by the campaign own CPM when client_cpm_rub is set, not the global default', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000, '90.00');
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
// 2500 показов × 90.00₽/1000 = 225.00 (не 300.00, как было бы по глобальной цене 120.00).
expect($campaign->charged_client_rub)->toBe('225.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9775.00');
});
it('records our margin-adjusted yandex cost alongside the client charge (40% margin default)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
// charged_client_rub = 300.00 (дефолт 120.00₽/1000), margin 40% → yandex_cost_rub = 60% от 300.00 = 180.00.
expect($campaign->charged_client_rub)->toBe('300.00')
->and(DB::table('ad_campaigns')->where('id', $campaign->id)->value('yandex_cost_rub'))->toBe('180.00');
});
it('keeps charged_client_rub monotonic when a later report returns fewer impressions', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$charger = app(CampaignImpressionCharger::class);
$charger->charge($campaign, 6000); // 6000 × 120/1000 = 720.00
$charger->charge($campaign->refresh(), 2500); // отчёт «просел» до 2500 — база НЕ опускается
$campaign->refresh();
// charged_client_rub держится на high-water 720.00, а не откатывается к 300.00.
expect($campaign->charged_client_rub)->toBe('720.00')
->and($campaign->delivered_impressions)->toBe(2500); // метрика показов отражает факт
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9280.00'); // 10000 − 720, повторно не списали
// Повторный рост до 6000 не даёт двойного списания (external_key уже был).
$charger->charge($campaign->refresh(), 6000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('720.00');
expect(AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('9280.00');
});
/**
* ВЫХОД 1, второе условие — срок показа истёк.
*
* Медийка по списку телефонов почти всегда НЕ добирает смету: аудитория ограничена,
* частота показов ограничена. Без этого условия статус вечно оставался бы `running`,
* а заморозка — ACTIVE навсегда: деньги клиента заперты в кампании, которая уже
* ничего не показывает (EndDate в Директе прошёл, delivered замер).
*/
it('completes an under-delivered campaign once its show period has ended and releases the hold', function () {
$tenant = Tenant::factory()->create();
$svc = app(AdWalletService::class);
$svc->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$campaign->update(['shows_until' => now()->subDay()->toDateString()]);
$svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
expect($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED)
->and($campaign->charged_client_rub)->toBe('300.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->frozen_rub)->toBe('0.00') // остаток сметы разморожен
->and($wallet->balance_rub)->toBe('9700.00'); // списано только за фактические показы
});
it('keeps the campaign running while its show period is still open', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$campaign->update(['shows_until' => now()->addDay()->toDateString()]);
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('keeps the campaign running on the last day of the show period', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$campaign->update(['shows_until' => now()->toDateString()]); // последний день — ещё крутится
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING);
});
it('charges nothing for zero delivered impressions', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 5000);
app(CampaignImpressionCharger::class)->charge($campaign, 0);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('0.00')
->and($campaign->delivered_impressions)->toBe(0)
->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('10000.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});