447ad6af49
Приёмка 03.08.2026 на боевом нашла три дыры вокруг одной и той же суммы: у клиента заморожены 3 333,36 ₽, и узнать за что было негде. Деньги не терялись — терялось объяснение, а для клиента, у которого заперта треть кошелька, это неотличимо от пропажи. 1) Денежная плашка врала после паузы и возобновления (Я-Ф1/Я-Ф4 FAIL). Она грузилась ОДИН раз, при открытии страницы, и о переменах не узнавала. После «Возобновить» экран обещал 9 991 ₽ свободных, когда на деле оставалось 6 657,64 ₽: клиент считал новую кампанию на несуществующие деньги и получал отказ, которого не ждал. Правда появлялась только после перезагрузки. Стало: список кампаний сообщает «деньги подвинулись», экран перечитывает плашку. Сигнал шлют ровно те три действия, что двигают деньги: запуск, пауза, возобновление. Сторож держит не содержимое плашки (оно зелено и при поломке), а сам факт повторного обращения за кошельком после денежного действия. 2) Летопись не фиксировала ни постановку заморозки, ни снятие. Заморозка писалась суммой 0,00 ₽ — жёстко зашитой, снятие не писалось вовсе. Стало: заморозка пишется настоящей суммой со знаком минус (деньги ушли из свободных, баланс не двигается), снятие — отдельной строкой типа release. Подпись человеческая и с номером: «Заморозили под кампанию №6». 3) На экране кошелька вместо истории стояло «появится позже». Стало: живая история движений — новая ручка GET /api/advertising/wallet/transactions (последние 100, свежие сверху, чужие не отдаёт) и лента на экране: подпись человеческими словами, сумма со знаком, дата по Москве. У заморозки и её снятия отдельная пометка «баланс не изменился», чтобы клиент не принял резерв за списание. 4) Карточка кампании называла не ту сумму. Показывалось поле budget_rub — число, которое клиент когда-то вписал сам и которое в расчёте денег не участвует вообще: «Бюджет показов 200 ₽» при заморозке 3 333,36 ₽. Стало: «Смета показов» — считанная сервером (показы × цена за 1000, та же арифметика, что и у заморозки), плюс строка «Зарезервировано» с живой бронёй по этой кампании. Схема не менялась — все поля летописи уже были. Проверено: 375 сторожей рекламы, 748 по смежным кошелькам (телеграм и СМС ходят тем же сервисом), 2042 на фронте — все зелёные. Статанализ 0 ошибок, типы Vue чисты, стиль в изменённых файлах чист. Все четыре сторожа приняты вырезанием: на старом коде красные. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
4.7 KiB
PHP
113 lines
4.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\AdCampaign;
|
||
use App\Models\AdWallet;
|
||
use App\Models\Tenant;
|
||
use App\Models\User;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Карточка кампании обязана называть ту сумму, которая заперта на самом деле.
|
||
*
|
||
* Приёмка 03.08.2026 на боевом: на карточке было написано «Бюджет показов 200 ₽» — это поле
|
||
* `budget_rub`, число, которое клиент когда-то вписал сам; в расчёте денег оно не участвует
|
||
* вообще. Настоящая смета той кампании — 27 778 показов × 120 ₽/1000 = 3 333,36 ₽, и именно
|
||
* она была заморожена. Клиент видел одно число, а заперты у него были другие деньги.
|
||
*
|
||
* Сторож держит правило: список кампаний отдаёт СЧИТАННУЮ смету и то, сколько по кампании
|
||
* сейчас реально зарезервировано.
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
beforeEach(function () {
|
||
$this->tenant = Tenant::factory()->create();
|
||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||
$this->actingAs($this->user);
|
||
DB::table('ad_settings')->update(['client_cpm_rub' => '120.00']);
|
||
});
|
||
|
||
it('отдаёт настоящую смету, а не число, вписанное клиентом', function () {
|
||
$campaign = AdCampaign::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => AdCampaign::STATUS_RUNNING,
|
||
'name' => 'Реклама от 30.07.2026',
|
||
'audience_days' => 30,
|
||
'frequency' => 10,
|
||
'estimated_impressions' => 27778,
|
||
'budget_rub' => '200.00', // то самое число «из воздуха»
|
||
'client_cpm_rub' => '120.00',
|
||
]);
|
||
|
||
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
|
||
$row = collect($items)->firstWhere('id', $campaign->id);
|
||
|
||
// 27 778 × 120 / 1000 = 3 333,36 ₽
|
||
expect($row['smeta_rub'])->toBe('3333.36');
|
||
});
|
||
|
||
it('показывает, сколько по кампании реально зарезервировано', function () {
|
||
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '9991.00', 'frozen_rub' => '0.00']);
|
||
|
||
$campaign = AdCampaign::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => AdCampaign::STATUS_RUNNING,
|
||
'name' => 'Реклама от 30.07.2026',
|
||
'audience_days' => 30,
|
||
'frequency' => 10,
|
||
'estimated_impressions' => 27778,
|
||
'client_cpm_rub' => '120.00',
|
||
]);
|
||
|
||
app(AdWalletService::class)->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '3333.36');
|
||
|
||
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
|
||
$row = collect($items)->firstWhere('id', $campaign->id);
|
||
|
||
expect($row['frozen_rub'])->toBe('3333.36');
|
||
});
|
||
|
||
it('у кампании без заморозки резерв — ноль, а не пусто', function () {
|
||
$campaign = AdCampaign::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => AdCampaign::STATUS_DRAFT,
|
||
'name' => 'Черновик',
|
||
'audience_days' => 30,
|
||
'frequency' => 10,
|
||
'estimated_impressions' => 1000,
|
||
'client_cpm_rub' => '120.00',
|
||
]);
|
||
|
||
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
|
||
$row = collect($items)->firstWhere('id', $campaign->id);
|
||
|
||
expect($row['frozen_rub'])->toBe('0.00')
|
||
->and($row['smeta_rub'])->toBe('120.00');
|
||
});
|
||
|
||
it('снятая заморозка в резерве больше не числится', function () {
|
||
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '9991.00', 'frozen_rub' => '0.00']);
|
||
|
||
$campaign = AdCampaign::create([
|
||
'tenant_id' => $this->tenant->id,
|
||
'status' => AdCampaign::STATUS_PAUSED,
|
||
'name' => 'На паузе',
|
||
'audience_days' => 30,
|
||
'frequency' => 10,
|
||
'estimated_impressions' => 27778,
|
||
'client_cpm_rub' => '120.00',
|
||
]);
|
||
|
||
$svc = app(AdWalletService::class);
|
||
$svc->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '3333.36');
|
||
$svc->release($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id);
|
||
|
||
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
|
||
$row = collect($items)->firstWhere('id', $campaign->id);
|
||
|
||
expect($row['frozen_rub'])->toBe('0.00');
|
||
});
|