f3d1d23b9c
Задача 8b. Админ-экран «Реклама: расход и маржа» переведён со старой модели наценка 30% делением на единую модель показов наценка 40% вычитанием, как в CampaignImpressionCharger. yandex_cost = client_spend умножить на долю 1 минус ad_margin_percent/100. Поле ответа markup_percent переименовано в ad_margin_percent, фронт-вью и тип обновлены. Мёртвый класс AdMarkup и его тест удалены полностью. Колонка ad_settings markup_percent осталась в схеме, но больше не читается. Тесты: бэк AdminAdvertisingSpend 6/6, реклама-модуль 189/189, фронт админки 7/7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
4.8 KiB
PHP
120 lines
4.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Tenant;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
|
||
/**
|
||
* T4a: SaaS-admin «Реклама: расход и наша маржа» — агрегат по ad_wallet_transactions
|
||
* (type=charge, channel=yandex). Наценка глобальная — ad_settings.ad_margin_percent,
|
||
* доля НАШЕЙ маржи в клиентской цене; yandex_cost = client_spend × (1 − ad_margin_percent/100).
|
||
*/
|
||
function makeAdCharge(
|
||
int $tenantId,
|
||
float $amount,
|
||
?string $createdAt = null,
|
||
string $channel = 'yandex',
|
||
string $type = 'charge',
|
||
): void {
|
||
DB::table('ad_wallet_transactions')->insert([
|
||
'tenant_id' => $tenantId,
|
||
'type' => $type,
|
||
'amount_rub' => $type === 'charge' ? -$amount : $amount,
|
||
'balance_rub_after' => '0.00',
|
||
'channel' => $channel,
|
||
'related_type' => 'campaign',
|
||
'related_id' => 1,
|
||
'created_at' => $createdAt ?? now(),
|
||
]);
|
||
}
|
||
|
||
test('GET /api/admin/advertising/spend считает расход/маржу по тенантам (390/234/156 и 100/60/40)', function () {
|
||
$a = Tenant::factory()->create(['organization_name' => 'Окна Москва']);
|
||
$b = Tenant::factory()->create(['organization_name' => 'Двери СПб']);
|
||
|
||
makeAdCharge($a->id, 260.00);
|
||
makeAdCharge($a->id, 130.00);
|
||
makeAdCharge($b->id, 100.00);
|
||
|
||
$r = $this->getJson('/api/admin/advertising/spend');
|
||
$r->assertStatus(200);
|
||
|
||
expect($r->json('ad_margin_percent'))->toBe('40.00');
|
||
|
||
$rowA = collect($r->json('data'))->firstWhere('tenant_id', $a->id);
|
||
expect($rowA['tenant_name'])->toBe('Окна Москва');
|
||
expect($rowA['client_spend_rub'])->toBe('390.00');
|
||
expect($rowA['yandex_cost_rub'])->toBe('234.00');
|
||
expect($rowA['our_margin_rub'])->toBe('156.00');
|
||
|
||
$rowB = collect($r->json('data'))->firstWhere('tenant_id', $b->id);
|
||
expect($rowB['client_spend_rub'])->toBe('100.00');
|
||
expect($rowB['yandex_cost_rub'])->toBe('60.00');
|
||
expect($rowB['our_margin_rub'])->toBe('40.00');
|
||
|
||
expect($r->json('totals.client_spend_rub'))->toBe('490.00');
|
||
expect($r->json('totals.yandex_cost_rub'))->toBe('294.00');
|
||
expect($r->json('totals.our_margin_rub'))->toBe('196.00');
|
||
});
|
||
|
||
test('GET /api/admin/advertising/spend сортирует по client_spend_rub desc', function () {
|
||
$small = Tenant::factory()->create();
|
||
$big = Tenant::factory()->create();
|
||
|
||
makeAdCharge($small->id, 50.00);
|
||
makeAdCharge($big->id, 500.00);
|
||
|
||
$r = $this->getJson('/api/admin/advertising/spend');
|
||
|
||
expect($r->json('data.0.tenant_id'))->toBe($big->id);
|
||
expect($r->json('data.1.tenant_id'))->toBe($small->id);
|
||
});
|
||
|
||
test('GET /api/admin/advertising/spend игнорирует не-charge и не-yandex строки', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
makeAdCharge($tenant->id, 200.00); // считается
|
||
makeAdCharge($tenant->id, 999.00, null, 'sms'); // другой канал — не считается
|
||
makeAdCharge($tenant->id, 999.00, null, 'yandex', 'topup'); // не charge — не считается
|
||
|
||
$r = $this->getJson('/api/admin/advertising/spend');
|
||
|
||
$row = collect($r->json('data'))->firstWhere('tenant_id', $tenant->id);
|
||
expect($row['client_spend_rub'])->toBe('200.00');
|
||
});
|
||
|
||
test('GET /api/admin/advertising/spend не включает тенанта без расхода', function () {
|
||
Tenant::factory()->create(); // без единой ad_wallet_transactions строки
|
||
|
||
$r = $this->getJson('/api/admin/advertising/spend');
|
||
|
||
expect($r->json('data'))->toBe([]);
|
||
expect($r->json('totals.client_spend_rub'))->toBe('0.00');
|
||
});
|
||
|
||
test('GET /api/admin/advertising/spend period=current_month не включает прошлый месяц, period=all включает', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
|
||
makeAdCharge($tenant->id, 150.00); // текущий месяц
|
||
makeAdCharge($tenant->id, 700.00, now()->subMonths(2)->toDateTimeString()); // 2 месяца назад
|
||
|
||
$current = $this->getJson('/api/admin/advertising/spend?period=current_month');
|
||
$row = collect($current->json('data'))->firstWhere('tenant_id', $tenant->id);
|
||
expect($row['client_spend_rub'])->toBe('150.00');
|
||
|
||
$all = $this->getJson('/api/admin/advertising/spend?period=all');
|
||
$rowAll = collect($all->json('data'))->firstWhere('tenant_id', $tenant->id);
|
||
expect($rowAll['client_spend_rub'])->toBe('850.00');
|
||
});
|
||
|
||
test('GET /api/admin/advertising/spend — гейт: включён + нет REMOTE_USER → 403', function () {
|
||
config(['admin.basic_auth_gate' => true]);
|
||
config(['admin.basic_auth_allowlist' => ['admin']]);
|
||
|
||
$this->getJson('/api/admin/advertising/spend')->assertStatus(403);
|
||
});
|