Files
portal/app/tests/Feature/Sales/SalesOverviewTest.php
T
Дмитрий 3b8694cd4c feat(sales): живой доход по клиентам в списке/карточке/сводке (бэк)
Task 3.3a — SalesClientsController наполняет earned_rub через
SalesEarningsService (был null-заглушкой):
- index: доход по каждому клиенту за период по снимку тарифа привязки;
  клиент без привязки (начальник видит незакреплённых) → null.
- show (kpi): доход по одному клиенту.
- overview (totals): сумма комиссии по всем привязкам в scope (без оклада).

Тесты Фазы 1 обновлены под реальные значения (percent_oborot 10/20% от
засеянного оборота → 500/5/10000 ₽; привязка без тарифа → 0.0; без
привязки → null). Попутно исправлен ключ params тест-тарифа
(percent→rate, движок читает rate). Sales-набор 132/132, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:32:00 +03:00

400 lines
15 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\SalesClientAssignment;
use App\Models\SalesUser;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Testing\TestResponse;
/**
* TDD: GET /api/sales/overview — «Сводка менеджера».
*
* Покрывает Task 1.5 портала продаж:
* - Менеджер видит агрегаты только по СВОИМ клиентам.
* - Начальник видит агрегаты по ВСЕМ клиентам.
* - Менеджер без клиентов → clients_count=0, пустые массивы.
* - totals: clients_count, active/trial/overdue, balance_sum_rub, leads_delivered, oborot_rub, earned_rub=null.
* - attention: клиенты с overdue/suspended/runway_days=0, отсортированы worst-first, ≤10.
* - top_clients: топ-4 по leads_delivered за период, desc.
*
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
* SharesAdminPdo применяется глобально в Pest.php.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.5)
*/
uses(DatabaseTransactions::class);
// ── helpers ───────────────────────────────────────────────────────────────────
function ov_makeSalesUser(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => ucfirst($role).' '.uniqid(),
'email' => 'ov-test-'.$role.uniqid().'@test.local',
'password' => Hash::make('secret'),
'role' => $role,
'is_active' => true,
]);
}
/**
* @param array<string,mixed> $params
*/
function ov_getOverview(SalesUser $user, array $params = []): TestResponse
{
$token = $user->createToken('sales')->plainTextToken;
$url = '/api/sales/overview';
if ($params !== []) {
$url .= '?'.http_build_query($params);
}
return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url);
}
function ov_makeTenant(
string $orgName,
bool $isTrial = false,
string $status = 'active',
float $balance = 500.0,
float $chargeback = 0.0,
): Tenant {
return Tenant::factory()->create([
'organization_name' => $orgName,
'status' => $status,
'is_trial' => $isTrial,
'balance_rub' => (string) $balance,
'chargeback_unrecovered_rub' => (string) $chargeback,
]);
}
function ov_assign(SalesUser $manager, Tenant $tenant): SalesClientAssignment
{
return SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => null,
'tariff_kind' => null,
'tariff_params' => [],
'assigned_at' => now(),
]);
}
/**
* Вставляет deal (лид) в текущем месяце, чтобы не упасть на отсутствии партиции.
*/
function ov_insertDeal(int $tenantId, string $receivedAt): void
{
$projectId = (int) DB::table('projects')
->where('tenant_id', $tenantId)
->value('id');
if ($projectId === 0) {
$projectId = (int) DB::table('projects')->insertGetId([
'tenant_id' => $tenantId,
'name' => 'Overview Test Project',
'tag' => 'ov-test-'.uniqid(),
'is_active' => true,
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('deals')->insert([
'tenant_id' => $tenantId,
'project_id' => $projectId,
'source_crm_id' => rand(100_000_000, 999_999_999),
'phone' => '7'.str_pad((string) rand(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'status' => 'new',
'is_test' => false,
'received_at' => $receivedAt,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* Вставляет lead_charge в текущем месяце (чтобы партиция существовала).
*
* @param int $kopecks цена лида в копейках
*/
function ov_insertCharge(int $tenantId, int $projectId, string $chargedAt, int $kopecks): void
{
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'project_id' => $projectId,
'source_crm_deal_id' => rand(100_000_000, 999_999_999),
'price_per_lead_kopecks' => $kopecks,
'charged_at' => $chargedAt,
'created_at' => now(),
'updated_at' => now(),
]);
}
// ── тесты ────────────────────────────────────────────────────────────────────
test('менеджер: totals по 3 своим клиентам — active/trial/overdue, суммы', function () {
$manager = ov_makeSalesUser('manager');
// active с балансом 1000
$tActive = ov_makeTenant('Актив', false, 'active', 1000.0);
// trial
$tTrial = ov_makeTenant('Триал', true, 'active', 200.0);
// overdue — balance < 0
$tOverdue = ov_makeTenant('Должник', false, 'active', -50.0);
ov_assign($manager, $tActive);
ov_assign($manager, $tTrial);
ov_assign($manager, $tOverdue);
// Добавляем 2 лида к активному тенанту в текущем месяце
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
ov_insertDeal($tActive->id, $nowStr);
ov_insertDeal($tActive->id, $nowStr);
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
$totals = $response->json('totals');
expect($totals['clients_count'])->toBe(3);
expect($totals['active'])->toBe(1);
expect($totals['trial'])->toBe(1);
expect($totals['overdue'])->toBeGreaterThanOrEqual(1); // overdue включает должника
expect($totals['leads_delivered'])->toBe(2);
// Привязки без тарифа (tariff_kind=null) → доход 0.0 (не null).
expect((float) $totals['earned_rub'])->toBe(0.0);
// balance_sum_rub = 1000 + 200 + (-50) = 1150
expect((float) $totals['balance_sum_rub'])->toBe(1150.0);
});
test('менеджер: totals.earned_rub — сумма комиссии по тарифам клиентов', function () {
$manager = ov_makeSalesUser('manager');
$tenant = ov_makeTenant('Комиссия', false, 'active', 1000.0);
// Привязка со снимком тарифа percent_oborot 20%.
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => null,
'tariff_kind' => 'percent_oborot',
'tariff_params' => ['rate' => 20],
'assigned_at' => now(),
]);
// Оборот текущего месяца: 5 000 000 копеек = 50 000 ₽ → комиссия 20% = 10 000 ₽.
DB::table('lead_charges')->insert([
'tenant_id' => $tenant->id,
'deal_id' => 1,
'deal_received_at' => now(),
'tier_no' => 1,
'price_per_lead_kopecks' => 5_000_000,
'charge_source' => 'rub',
'charged_at' => now(),
'created_at' => now(),
]);
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
expect((float) $response->json('totals.earned_rub'))->toBe(10000.0);
});
test('менеджер: attention содержит overdue-клиента', function () {
$manager = ov_makeSalesUser('manager');
// Overdue: баланс < 0 — всегда в attention
$tOverdue = ov_makeTenant('Должник', false, 'active', -100.0);
ov_assign($manager, $tOverdue);
$response = ov_getOverview($manager);
$response->assertOk();
$attention = $response->json('attention');
$attentionIds = array_column($attention, 'tenant_id');
expect($attentionIds)->toContain($tOverdue->id);
});
test('менеджер: attention содержит suspended-клиента', function () {
$manager = ov_makeSalesUser('manager');
$tSuspended = ov_makeTenant('Приостановлен', false, 'suspended', 0.0);
ov_assign($manager, $tSuspended);
$response = ov_getOverview($manager);
$response->assertOk();
$attention = $response->json('attention');
$attentionIds = array_column($attention, 'tenant_id');
expect($attentionIds)->toContain($tSuspended->id);
});
test('менеджер: top_clients — топ-4 по leads_delivered, desc', function () {
$manager = ov_makeSalesUser('manager');
// Создаём 5 клиентов и добавляем им разное кол-во лидов
$tenants = [];
for ($i = 0; $i < 5; $i++) {
$t = ov_makeTenant("Клиент $i");
ov_assign($manager, $t);
$tenants[$i] = $t;
}
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
// Лиды: [10, 7, 5, 3, 1]
$leadCounts = [10, 7, 5, 3, 1];
foreach ($leadCounts as $idx => $cnt) {
for ($j = 0; $j < $cnt; $j++) {
ov_insertDeal($tenants[$idx]->id, $nowStr);
}
}
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
$top = $response->json('top_clients');
expect($top)->toHaveCount(4);
// Первый — максимальное кол-во лидов (10)
expect($top[0]['leads_delivered'])->toBe(10);
expect($top[0]['tenant_id'])->toBe($tenants[0]->id);
// Убеждаемся в убывании
for ($i = 0; $i < 3; $i++) {
expect($top[$i]['leads_delivered'])->toBeGreaterThanOrEqual($top[$i + 1]['leads_delivered']);
}
});
test('начальник: видит агрегаты по ВСЕМ клиентам, включая незакреплённых', function () {
$head = ov_makeSalesUser('head');
$manager = ov_makeSalesUser('manager');
// Три тенанта, один из которых не закреплён ни за кем
$t1 = ov_makeTenant('Закреплён 1', false, 'active', 100.0);
$t2 = ov_makeTenant('Закреплён 2', true, 'active', 200.0);
$t3 = ov_makeTenant('Без менеджера', false, 'active', 300.0);
ov_assign($manager, $t1);
ov_assign($manager, $t2);
// t3 — не назначен никому
$response = ov_getOverview($head);
$response->assertOk();
$totals = $response->json('totals');
// Начальник должен видеть как минимум эти 3 (может быть больше если есть другие тенанты в тест-БД)
expect($totals['clients_count'])->toBeGreaterThanOrEqual(3);
$attentionAndTop = [
...(array) $response->json('attention'),
...(array) $response->json('top_clients'),
];
$allIds = array_unique([
...array_column((array) $response->json('attention'), 'tenant_id'),
...array_column((array) $response->json('top_clients'), 'tenant_id'),
]);
// Хотя бы один из трёх тенантов должен присутствовать в ответе начальника
// (либо в attention, либо в top)
// Более строгая проверка — clients_count включает все 3
// Проверяем через баланс-сумму (≥600 — все три наших тенанта)
expect((float) $totals['balance_sum_rub'])->toBeGreaterThanOrEqual(600.0);
});
test('менеджер без клиентов: clients_count=0, пустые массивы', function () {
$manager = ov_makeSalesUser('manager');
// Создаём другого менеджера с тенантом — наш менеджер не должен его видеть
$otherManager = ov_makeSalesUser('manager');
$t = ov_makeTenant('Чужой', false, 'active', 500.0);
ov_assign($otherManager, $t);
$response = ov_getOverview($manager);
$response->assertOk();
$totals = $response->json('totals');
expect($totals['clients_count'])->toBe(0);
expect($totals['active'])->toBe(0);
expect($totals['trial'])->toBe(0);
expect($totals['overdue'])->toBe(0);
expect($totals['leads_delivered'])->toBe(0);
// Нет клиентов → доход 0.0.
expect((float) $totals['earned_rub'])->toBe(0.0);
expect($response->json('attention'))->toBeArray()->toHaveCount(0);
expect($response->json('top_clients'))->toBeArray()->toHaveCount(0);
});
test('structure: ответ содержит все обязательные ключи', function () {
$manager = ov_makeSalesUser('manager');
$response = ov_getOverview($manager);
$response->assertOk();
$response->assertJsonStructure([
'totals' => [
'clients_count',
'active',
'trial',
'overdue',
'balance_sum_rub',
'leads_delivered',
'oborot_rub',
'earned_rub',
],
'attention',
'top_clients',
]);
});
test('attention: поля tenant_id, organization_name, status, balance_rub, runway_days', function () {
$manager = ov_makeSalesUser('manager');
$tOverdue = ov_makeTenant('Должник', false, 'active', -200.0);
ov_assign($manager, $tOverdue);
$response = ov_getOverview($manager);
$response->assertOk();
$attention = $response->json('attention');
expect($attention)->toBeArray()->not->toHaveCount(0);
$first = $attention[0];
expect($first)->toHaveKey('tenant_id');
expect($first)->toHaveKey('organization_name');
expect($first)->toHaveKey('status');
expect($first)->toHaveKey('balance_rub');
expect($first)->toHaveKey('runway_days');
});
test('top_clients: поля tenant_id, organization_name, leads_delivered, oborot_rub', function () {
$manager = ov_makeSalesUser('manager');
$t = ov_makeTenant('Топ Клиент');
ov_assign($manager, $t);
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
ov_insertDeal($t->id, $nowStr);
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
$top = $response->json('top_clients');
expect($top)->toBeArray()->not->toHaveCount(0);
$first = $top[0];
expect($first)->toHaveKey('tenant_id');
expect($first)->toHaveKey('organization_name');
expect($first)->toHaveKey('leads_delivered');
expect($first)->toHaveKey('oborot_rub');
});
test('запрос без токена → 401', function () {
test()->getJson('/api/sales/overview')->assertUnauthorized();
});