2026-07-02 06:34:22 +03:00
|
|
|
|
<?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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-02 19:32:00 +03:00
|
|
|
|
test('строка ответа содержит нужные поля и earned_rub по тарифу', function () {
|
2026-07-02 06:34:22 +03:00
|
|
|
|
$manager = clients_makeSalesUser('manager');
|
|
|
|
|
|
|
|
|
|
|
|
$tariff = SalesTariff::create([
|
|
|
|
|
|
'name' => 'Тариф Тест',
|
2026-07-05 18:51:41 +03:00
|
|
|
|
'kind' => 'topup_step',
|
|
|
|
|
|
'params' => ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]],
|
2026-07-02 06:34:22 +03:00
|
|
|
|
'is_active' => true,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
$t1 = clients_makeTenantWithInn('301', 'ООО Тест');
|
|
|
|
|
|
clients_assignTenant($manager, $t1, $tariff);
|
|
|
|
|
|
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// Оборот текущего месяца: 500 000 копеек = 5 000 ₽ (метрика oborot_rub).
|
2026-07-02 19:32:00 +03:00
|
|
|
|
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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// Пополнения 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(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-07-02 06:34:22 +03:00
|
|
|
|
$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('Тариф Тест');
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// earned_rub = пополнения 5 000 ₽ × 10% = 500.0 (по снимку тарифа привязки).
|
2026-07-02 19:32:00 +03:00
|
|
|
|
// (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();
|
2026-07-02 06:34:22 +03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|