d26716ed90
Р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>
234 lines
10 KiB
PHP
234 lines
10 KiB
PHP
<?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);
|
||
});
|