Files
portal/app/tests/Feature/Sales/SalesClientsIndexTest.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

336 lines
12 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\SalesTariff;
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/clients — экран «Мои клиенты».
*
* Покрывает Task 1.3 портала продаж:
* - Менеджер видит только своих клиентов.
* - Начальник видит всех.
* - Менеджер без клиентов видит 0.
* - Строка ответа содержит organization_name, inn, status, tariff_name, earned_rub=null.
* - Параметр period влияет на leads_delivered.
*
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
* SharesAdminPdo применяется глобально в Pest.php — admin-db middleware
* переключает default→pgsql_admin, sharing PDO обеспечивает видимость
* засеянных данных в запросах.
*
* Аутентификация: создаём SalesUser и передаём Bearer-токен вручную через
* $user->createToken('sales')->plainTextToken.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3)
*/
uses(DatabaseTransactions::class);
// ── helpers (prefixed с clients_ чтобы не конфликтовать с SalesModelsTest) ───
/**
* Создаёт SalesUser для тестов clients-эндпоинта.
*/
function clients_makeSalesUser(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => ucfirst($role).' '.uniqid(),
'email' => 'clients-test-'.$role.uniqid().'@test.local',
'password' => Hash::make('secret'),
'role' => $role,
'is_active' => true,
]);
}
/**
* Выполняет GET /api/sales/clients с Bearer-токеном пользователя.
*
* @param array<string,mixed> $params
*/
function clients_getClients(SalesUser $user, array $params = []): TestResponse
{
$token = $user->createToken('sales')->plainTextToken;
$url = '/api/sales/clients';
if ($params !== []) {
$url .= '?'.http_build_query($params);
}
return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url);
}
/**
* Создаёт тенанта с tenant_requisites (inn).
*/
function clients_makeTenantWithInn(string $inn, string $orgName = ''): Tenant
{
$tenant = Tenant::factory()->create([
'organization_name' => $orgName !== '' ? $orgName : 'Org '.$inn,
'status' => 'active',
'is_trial' => false,
'balance_rub' => '0.00',
'chargeback_unrecovered_rub' => '0.00',
]);
DB::table('tenant_requisites')->insert([
'tenant_id' => $tenant->id,
'subject_type' => 'legal_entity',
'contact_name' => 'Контакт',
'contact_phone' => '+70000000000',
'inn' => $inn,
'created_at' => now(),
'updated_at' => now(),
]);
return $tenant;
}
/**
* Присваивает тенанта менеджеру с опциональным снимком тарифа.
*/
function clients_assignTenant(SalesUser $manager, Tenant $tenant, ?SalesTariff $tariff = null): SalesClientAssignment
{
return SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => $tariff?->id,
'tariff_kind' => $tariff?->kind,
'tariff_params' => $tariff !== null ? $tariff->params : [],
'assigned_at' => now(),
]);
}
/**
* Вставляет deal для теста period-фильтра.
*/
function clients_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' => 'Period Test Project',
'tag' => 'period-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(),
]);
}
// ── тесты ────────────────────────────────────────────────────────────────────
test('менеджер видит только своих 2 клиентов из 3', function () {
$manager = clients_makeSalesUser('manager');
$manager2 = clients_makeSalesUser('manager');
$t1 = clients_makeTenantWithInn('001', 'Клиент 1');
$t2 = clients_makeTenantWithInn('002', 'Клиент 2');
$t3 = clients_makeTenantWithInn('003', 'Клиент 3');
clients_assignTenant($manager, $t1);
clients_assignTenant($manager, $t2);
clients_assignTenant($manager2, $t3);
$response = clients_getClients($manager);
$response->assertOk();
$data = $response->json('data');
expect($data)->toBeArray()->toHaveCount(2);
$tenantIds = array_column($data, 'tenant_id');
expect($tenantIds)->toContain($t1->id)->toContain($t2->id)
->not->toContain($t3->id);
});
test('начальник видит всех 3 клиентов', function () {
$head = clients_makeSalesUser('head');
$manager = clients_makeSalesUser('manager');
$t1 = clients_makeTenantWithInn('101');
$t2 = clients_makeTenantWithInn('102');
$t3 = clients_makeTenantWithInn('103');
clients_assignTenant($manager, $t1);
clients_assignTenant($manager, $t2);
clients_assignTenant($manager, $t3);
$response = clients_getClients($head);
$response->assertOk();
$data = $response->json('data');
$tenantIds = array_column($data, 'tenant_id');
expect($tenantIds)->toContain($t1->id)->toContain($t2->id)->toContain($t3->id);
});
test('менеджер без назначений видит 0 клиентов', function () {
$manager2 = clients_makeSalesUser('manager');
$manager1 = clients_makeSalesUser('manager');
$t1 = clients_makeTenantWithInn('201');
clients_assignTenant($manager1, $t1);
$response = clients_getClients($manager2);
$response->assertOk();
expect($response->json('data'))->toBeArray()->toHaveCount(0);
});
test('строка ответа содержит нужные поля и earned_rub по тарифу', function () {
$manager = clients_makeSalesUser('manager');
$tariff = SalesTariff::create([
'name' => 'Тариф Тест',
'kind' => 'percent_oborot',
'params' => ['rate' => 10],
'is_active' => true,
]);
$t1 = clients_makeTenantWithInn('301', 'ООО Тест');
clients_assignTenant($manager, $t1, $tariff);
// Оборот текущего месяца: 500 000 копеек = 5 000 ₽ → комиссия 10% = 500 ₽.
DB::table('lead_charges')->insert([
'tenant_id' => $t1->id,
'deal_id' => 1,
'deal_received_at' => now(),
'tier_no' => 1,
'price_per_lead_kopecks' => 500_000,
'charge_source' => 'rub',
'charged_at' => now(),
'created_at' => now(),
]);
$response = clients_getClients($manager);
$response->assertOk();
$data = $response->json('data');
expect($data)->toHaveCount(1);
$row = $data[0];
expect($row)->toHaveKey('tenant_id')
->toHaveKey('organization_name')
->toHaveKey('inn')
->toHaveKey('status')
->toHaveKey('tariff_name')
->toHaveKey('earned_rub')
->toHaveKey('leads_delivered')
->toHaveKey('oborot_rub')
->toHaveKey('runway_days')
->toHaveKey('projects_count');
expect($row['organization_name'])->toBe('ООО Тест');
expect($row['inn'])->toBe('301');
expect($row['tariff_name'])->toBe('Тариф Тест');
// earned_rub = оборот 5 000 ₽ × 10% = 500.0 (по снимку тарифа привязки).
// (float) — JSON кодирует целочисленный float как int.
expect((float) $row['earned_rub'])->toBe(500.0);
});
test('начальник: клиент без привязки → earned_rub null', function () {
$head = clients_makeSalesUser('head');
// Тенант, не закреплённый ни за одним менеджером.
$t = clients_makeTenantWithInn('777', 'Без менеджера');
$response = clients_getClients($head);
$response->assertOk();
$rows = collect($response->json('data'));
$row = $rows->firstWhere('tenant_id', $t->id);
expect($row)->not->toBeNull();
expect($row['earned_rub'])->toBeNull();
});
test('status derivation: is_trial → trial, suspended → suspended, balance_rub < 0 → overdue, active → active', function () {
$head = clients_makeSalesUser('head');
$manager = clients_makeSalesUser('manager');
$tTrial = Tenant::factory()->create(['is_trial' => true, 'status' => 'active', 'balance_rub' => '0.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tTrial);
$tSuspended = Tenant::factory()->create(['is_trial' => false, 'status' => 'suspended', 'balance_rub' => '0.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tSuspended);
$tOverdue = Tenant::factory()->create(['is_trial' => false, 'status' => 'active', 'balance_rub' => '-100.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tOverdue);
$tActive = Tenant::factory()->create(['is_trial' => false, 'status' => 'active', 'balance_rub' => '500.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tActive);
$response = clients_getClients($head);
$response->assertOk();
$data = $response->json('data');
$byId = [];
foreach ($data as $row) {
$byId[$row['tenant_id']] = $row['status'];
}
expect($byId[$tTrial->id])->toBe('trial');
expect($byId[$tSuspended->id])->toBe('suspended');
expect($byId[$tOverdue->id])->toBe('overdue');
expect($byId[$tActive->id])->toBe('active');
});
test('period=this включает лиды текущего месяца, period=prev — предыдущего', function () {
$manager = clients_makeSalesUser('manager');
$tenant = clients_makeTenantWithInn('401', 'Тест период');
clients_assignTenant($manager, $tenant);
$nowMsk = CarbonImmutable::now('Europe/Moscow');
// Лид в текущем месяце (сегодня)
$inRange = $nowMsk->format('Y-m-d H:i:s');
// Лид в прошлом месяце (вне текущего периода)
$prevMonth = $nowMsk->subMonth()->startOfMonth()->format('Y-m-d H:i:s');
clients_insertDeal($tenant->id, $inRange);
clients_insertDeal($tenant->id, $prevMonth);
// period=this — 1 лид (только текущий месяц)
$respThis = clients_getClients($manager, ['period' => 'this']);
$respThis->assertOk();
$dataThis = $respThis->json('data');
expect($dataThis)->toHaveCount(1);
expect($dataThis[0]['leads_delivered'])->toBe(1);
// period=prev — 1 лид (прошлый месяц)
$respPrev = clients_getClients($manager, ['period' => 'prev']);
$respPrev->assertOk();
$dataPrev = $respPrev->json('data');
expect($dataPrev)->toHaveCount(1);
expect($dataPrev[0]['leads_delivered'])->toBe(1);
});
test('запрос без токена → 401', function () {
$this->getJson('/api/sales/clients')
->assertUnauthorized();
});