Files
portal/app/tests/Feature/Sales/SalesClientsIndexTest.php
T
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный
оклад daily_salary и процент от пополнений topup_step. Убраны
percent_oborot и fix_per_client.

Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает
FK sales_users.current_tariff_id и sales_client_assignments.tariff_id,
удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов
возвращает 0 по default-ветке. Снимки привязок не трогаются.

Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics,
фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта,
CHANGELOG схемы v8.61 и спека портала.

Не на проде: ветка feat/sales-portal-demo.

Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 19:25:19 +03:00

347 lines
13 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' => 'topup_step',
'params' => ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]],
'is_active' => true,
]);
$t1 = clients_makeTenantWithInn('301', 'ООО Тест');
clients_assignTenant($manager, $t1, $tariff);
// Оборот текущего месяца: 500 000 копеек = 5 000 ₽ (метрика oborot_rub).
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(),
]);
// Пополнения 5 000 ₽ текущего месяца → комиссия topup_step 10% = 500 ₽.
DB::table('balance_transactions')->insert([
'tenant_id' => $t1->id,
'type' => 'topup',
'amount_rub' => '5000.00',
'amount_leads' => 0,
'balance_rub_after' => '5000.00',
'description' => 'test',
'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();
});