Files
portal/app/tests/Feature/Sales/SalesClientsIndexTest.php
T
Дмитрий ad975c4d44 feat(sales): эндпоинт «Мои клиенты» (бэкенд)
Task 1.3a: GET /api/sales/clients — менеджер видит своих (ScopesSalesOwnership), начальник всех. Строки: организация, ИНН/тип лица (tenant_requisites), баланс, запас, проекты, лиды/оборот за период (SalesMetricsService), тариф-снимок из assignment, статус 1:1 с AdminTenantsController (trial>suspended>overdue>active). earned_rub=null до Фазы 3. Тест 7/7, stan 0 (baseline: Pest false-pos). Пагинация — TODO. Один эскейп на сессию.

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

307 lines
11 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=null', function () {
$manager = clients_makeSalesUser('manager');
$tariff = SalesTariff::create([
'name' => 'Тариф Тест',
'kind' => 'percent_oborot',
'params' => ['percent' => 10],
'is_active' => true,
]);
$t1 = clients_makeTenantWithInn('301', 'ООО Тест');
clients_assignTenant($manager, $t1, $tariff);
$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('Тариф Тест');
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();
});