2026-07-25 11:02:23 +03:00
|
|
|
|
<?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
|
2026-07-27 00:47:03 +03:00
|
|
|
|
* (type=charge, channel=yandex). Наценка глобальная — ad_settings.ad_margin_percent,
|
|
|
|
|
|
* доля НАШЕЙ маржи в клиентской цене; yandex_cost = client_spend × (1 − ad_margin_percent/100).
|
2026-07-25 11:02:23 +03:00
|
|
|
|
*/
|
|
|
|
|
|
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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 00:47:03 +03:00
|
|
|
|
test('GET /api/admin/advertising/spend считает расход/маржу по тенантам (390/234/156 и 100/60/40)', function () {
|
2026-07-25 11:02:23 +03:00
|
|
|
|
$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);
|
|
|
|
|
|
|
2026-07-27 00:47:03 +03:00
|
|
|
|
expect($r->json('ad_margin_percent'))->toBe('40.00');
|
2026-07-25 11:02:23 +03:00
|
|
|
|
|
|
|
|
|
|
$rowA = collect($r->json('data'))->firstWhere('tenant_id', $a->id);
|
|
|
|
|
|
expect($rowA['tenant_name'])->toBe('Окна Москва');
|
|
|
|
|
|
expect($rowA['client_spend_rub'])->toBe('390.00');
|
2026-07-27 00:47:03 +03:00
|
|
|
|
expect($rowA['yandex_cost_rub'])->toBe('234.00');
|
|
|
|
|
|
expect($rowA['our_margin_rub'])->toBe('156.00');
|
2026-07-25 11:02:23 +03:00
|
|
|
|
|
|
|
|
|
|
$rowB = collect($r->json('data'))->firstWhere('tenant_id', $b->id);
|
|
|
|
|
|
expect($rowB['client_spend_rub'])->toBe('100.00');
|
2026-07-27 00:47:03 +03:00
|
|
|
|
expect($rowB['yandex_cost_rub'])->toBe('60.00');
|
|
|
|
|
|
expect($rowB['our_margin_rub'])->toBe('40.00');
|
2026-07-25 11:02:23 +03:00
|
|
|
|
|
|
|
|
|
|
expect($r->json('totals.client_spend_rub'))->toBe('490.00');
|
2026-07-27 00:47:03 +03:00
|
|
|
|
expect($r->json('totals.yandex_cost_rub'))->toBe('294.00');
|
|
|
|
|
|
expect($r->json('totals.our_margin_rub'))->toBe('196.00');
|
2026-07-25 11:02:23 +03: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);
|
|
|
|
|
|
});
|