diff --git a/app/app/Http/Controllers/Api/Sales/SalesClientsController.php b/app/app/Http/Controllers/Api/Sales/SalesClientsController.php new file mode 100644 index 00000000..a3942f9c --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesClientsController.php @@ -0,0 +1,138 @@ +user('sales'); + + // 1. Период + $period = app(SalesPeriodResolver::class)->resolve([ + 'kind' => $request->query('period', 'this'), + 'from' => $request->query('from'), + 'to' => $request->query('to'), + ]); + + // 2. Tenant scope + $ids = $this->ownedTenantIds($user); + + // 3. Базовый запрос: tenants + LEFT JOIN tenant_requisites + LEFT JOIN assignment + tariff + $query = DB::table('tenants') + ->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id') + ->leftJoin('sales_client_assignments as sca', 'sca.tenant_id', '=', 'tenants.id') + ->leftJoin('sales_tariffs as st', 'st.id', '=', 'sca.tariff_id') + ->whereNull('tenants.deleted_at') + ->select([ + 'tenants.id as tenant_id', + 'tenants.organization_name', + 'tenants.status', + 'tenants.is_trial', + 'tenants.balance_rub', + 'tenants.chargeback_unrecovered_rub', + 'tenants.last_activity_at', + 'tenant_requisites.inn', + 'tenant_requisites.subject_type', + 'st.name as tariff_name', + ]); + + // Ограничение по владению: null = начальник (без ограничения) + if ($ids !== null) { + $query->whereIn('tenants.id', $ids === [] ? [-1] : $ids); + } + + // Поиск + $search = trim((string) $request->query('search', '')); + if ($search !== '') { + $like = '%'.$search.'%'; + $query->where(function ($q) use ($like): void { + $q->where('tenants.organization_name', 'ilike', $like) + ->orWhere('tenant_requisites.inn', 'ilike', $like); + }); + } + + $rows = $query + ->orderByDesc('tenants.last_activity_at') + ->orderBy('tenants.id') + ->get(); + + $metrics = app(SalesMetricsService::class); + + $data = $rows->map(function (object $row) use ($metrics, $period): array { + $tenantId = (int) $row->tenant_id; + + // projects_count: все проекты тенанта (без фильтра по is_active/archived). + // Counting all projects per tenant — active filter can be added if spec clarified. + $projectsCount = DB::table('projects') + ->where('tenant_id', $tenantId) + ->count(); + + // Производный статус — зеркалит AdminTenantsController CASE-логику: + // trial > suspended > overdue > active > else raw status. + $derivedStatus = match (true) { + (bool) $row->is_trial => 'trial', + $row->status === 'suspended' => 'suspended', + (float) $row->chargeback_unrecovered_rub > 0 || (float) $row->balance_rub < 0 => 'overdue', + $row->status === 'active' => 'active', + default => (string) $row->status, + }; + + return [ + 'tenant_id' => $tenantId, + 'organization_name' => $row->organization_name, + 'inn' => $row->inn, + 'subject_type' => $row->subject_type, + 'last_activity_at' => $row->last_activity_at !== null + ? CarbonImmutable::parse($row->last_activity_at)->toIso8601String() + : null, + 'balance_rub' => (string) $row->balance_rub, + 'status' => $derivedStatus, + 'tariff_name' => $row->tariff_name, + 'projects_count' => $projectsCount, + 'runway_days' => $metrics->runwayDays($tenantId), + 'leads_delivered' => $metrics->leadsDelivered($tenantId, $period), + 'oborot_rub' => $metrics->oborotRub($tenantId, $period), + 'earned_rub' => null, // Phase 3: tariff engine + ]; + })->all(); + + return response()->json(['data' => $data]); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 70a7ec62..4666d2da 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -276,6 +276,24 @@ parameters: count: 3 path: tests/Feature/Sales/SalesAuthTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesClientsIndexTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesClientsIndexTest.php + + - + message: '#^Access to an undefined property Pest\\Mixins\\Expectation\\:\:\$not\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Sales/SalesClientsIndexTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound diff --git a/app/routes/web.php b/app/routes/web.php index 6d1c2851..931da60a 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -1,6 +1,7 @@ prefix('api/sales/auth')->group(function () { }); // Зона данных портала (наполняется в Фазах 1–7). Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () { - // clients, attachments, income, tariffs, payouts, invoices, managers, dashboard + Route::get('/clients', [SalesClientsController::class, 'index']); + // attachments, income, tariffs, payouts, invoices, managers, dashboard }); // Plan 4 Task 11: tenant charges ledger (read-only + CSV export). diff --git a/app/tests/Feature/Sales/SalesClientsIndexTest.php b/app/tests/Feature/Sales/SalesClientsIndexTest.php new file mode 100644 index 00000000..b2803b51 --- /dev/null +++ b/app/tests/Feature/Sales/SalesClientsIndexTest.php @@ -0,0 +1,306 @@ +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 $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(); +});