diff --git a/app/app/Http/Controllers/Api/Sales/SalesClientsController.php b/app/app/Http/Controllers/Api/Sales/SalesClientsController.php index a3942f9c..28be45c0 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesClientsController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesClientsController.php @@ -15,22 +15,20 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; /** - * Портал продаж — экран «Мои клиенты». + * Портал продаж — экран «Мои клиенты» + карточка клиента. * - * GET /api/sales/clients + * GET /api/sales/clients — список (Task 1.3) + * GET /api/sales/clients/{tenantId} — карточка (Task 1.4) * * Менеджер видит только своих клиентов (через ScopesSalesOwnership); * начальник (role=head) видит всех. * - * Параметры: + * Параметры периода (оба метода): * ?period=this|prev|prev2|custom (default: this) * ?from=YYYY-MM-DD (только для period=custom) * ?to=YYYY-MM-DD (только для period=custom) - * ?search=... (ilike по organization_name / inn) * - * TODO: пагинация — добавить в следующей фазе. - * - * Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3) + * Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3, Task 1.4) */ class SalesClientsController extends Controller { @@ -135,4 +133,246 @@ class SalesClientsController extends Controller return response()->json(['data' => $data]); } + + /** + * Карточка клиента. + * + * GET /api/sales/clients/{tenantId} + * + * Менеджер может открыть только своего клиента (иначе 403). + * Начальник открывает любого. + * + * Ответ: + * profile — анкетные данные тенанта + реквизиты + * kpi — текущий баланс, runway, счётчики за период + * projects — список проектов тенанта + * leads_by_day — лиды по дням (last ~14 дней или в рамках периода) + * recent_leads — последние ~20 лидов (телефоны МАСКИРОВАНЫ) + * activity — последние ~10 balance_transactions + */ + public function show(Request $request, int $tenantId): JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + // 1. Проверка ownership: менеджер может смотреть только своих клиентов + $ids = $this->ownedTenantIds($user); + if ($ids !== null && ! in_array($tenantId, $ids, true)) { + abort(403, 'Этот клиент не закреплён за вами.'); + } + + // 2. Период для KPI-метрик + $period = app(SalesPeriodResolver::class)->resolve([ + 'kind' => $request->query('period', 'this'), + 'from' => $request->query('from'), + 'to' => $request->query('to'), + ]); + + // 3. Основные данные тенанта + реквизиты + $tenant = DB::table('tenants') + ->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id') + ->where('tenants.id', $tenantId) + ->whereNull('tenants.deleted_at') + ->select([ + 'tenants.id', + 'tenants.organization_name', + 'tenants.contact_email', + 'tenants.desired_daily_numbers', + 'tenants.balance_rub', + 'tenants.last_activity_at', + 'tenants.created_at', + 'tenants.status', + 'tenants.is_trial', + 'tenants.chargeback_unrecovered_rub', + 'tenant_requisites.contact_name', + 'tenant_requisites.contact_phone', + 'tenant_requisites.inn', + 'tenant_requisites.subject_type', + 'tenant_requisites.legal_address', + ]) + ->first(); + + if ($tenant === null) { + abort(404, 'Клиент не найден.'); + } + + // 4. Метрики + $metrics = app(SalesMetricsService::class); + $leadsDelivered = $metrics->leadsDelivered($tenantId, $period); + $oborotRub = $metrics->oborotRub($tenantId, $period); + $runwayDays = $metrics->runwayDays($tenantId); + + // projects_count: все проекты тенанта + $projectsCount = DB::table('projects') + ->where('tenant_id', $tenantId) + ->count(); + + // leads_target: сумма daily_limit_target активных проектов × число дней в периоде + $totalDailyTarget = (int) DB::table('projects') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->sum('daily_limit_target'); + + $daysInPeriod = (int) max(1, $period->start->diffInDays($period->end) + 1); + $leadsTarget = $totalDailyTarget * $daysInPeriod; + + $avgLeadPriceRub = $oborotRub / max(1, $leadsDelivered); + + // 5. Проекты + $projects = DB::table('projects') + ->where('tenant_id', $tenantId) + ->orderBy('id') + ->limit(100) + ->get() + ->map(fn (object $p): array => [ + 'id' => (int) $p->id, + 'name' => $p->name, + 'signal_type' => $p->signal_type, + 'region' => $p->regions ?? [], + 'daily_limit_target' => (int) $p->daily_limit_target, + 'delivered_today' => (int) $p->delivered_today, + 'status' => (bool) $p->is_active ? 'active' : 'paused', + ]) + ->all(); + + // 6. Лиды по дням (последние 14 дней) + // Оборот за каждый день подтягиваем одним запросом из lead_charges, + // сгруппированным по дню, и мержим с результатами deals. + $last14Start = CarbonImmutable::now('Europe/Moscow')->subDays(13)->startOfDay(); + $last14End = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addDay(); // завтра 00:00 + + $leadsByDayRows = DB::table('deals') + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->where('is_test', false) + ->where('received_at', '>=', $last14Start) + ->select([ + DB::raw("DATE(received_at AT TIME ZONE 'Europe/Moscow') as day"), + DB::raw('COUNT(*) as cnt'), + ]) + ->groupBy('day') + ->orderBy('day') + ->get(); + + // lead_charges за те же 14 дней, сгруппированные по дню (МСК) + $chargesByDayRows = DB::table('lead_charges') + ->where('tenant_id', $tenantId) + ->where('charged_at', '>=', $last14Start) + ->where('charged_at', '<', $last14End) + ->select([ + DB::raw("DATE(charged_at AT TIME ZONE 'Europe/Moscow') as day"), + DB::raw('SUM(price_per_lead_kopecks) as sum_kopecks'), + ]) + ->groupBy('day') + ->get() + ->keyBy('day'); + + $leadsByDayFormatted = $leadsByDayRows->map(function (object $row) use ($chargesByDayRows): array { + $dayStr = (string) $row->day; + $sumKopecks = isset($chargesByDayRows[$dayStr]) + ? (int) $chargesByDayRows[$dayStr]->sum_kopecks + : 0; + + return [ + 'date' => $dayStr, + 'count' => (int) $row->cnt, + 'oborot_rub' => $sumKopecks / 100, + ]; + })->all(); + + // 7. Последние лиды (~20), телефоны маскированы + $recentLeads = DB::table('deals') + ->leftJoin('projects', 'projects.id', '=', 'deals.project_id') + ->where('deals.tenant_id', $tenantId) + ->whereNull('deals.deleted_at') + ->where('deals.is_test', false) + ->orderByDesc('deals.received_at') + ->limit(20) + ->select([ + 'deals.received_at', + 'deals.phone', + 'deals.region_code', + 'deals.city', + 'projects.name as project_name', + 'projects.signal_type', + ]) + ->get() + ->map(fn (object $d): array => [ + 'received_at' => CarbonImmutable::parse($d->received_at)->toIso8601String(), + 'phone_masked' => $this->maskPhone($d->phone), + 'region' => $d->city ?? $d->region_code, + 'source' => ($d->project_name ?? '—').($d->signal_type !== null ? ' / '.$d->signal_type : ''), + 'project' => $d->project_name, + ]) + ->all(); + + // 8. Активность — последние 10 balance_transactions + $activity = DB::table('balance_transactions') + ->where('tenant_id', $tenantId) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(10) + ->select(['created_at', 'type', 'amount_rub', 'description']) + ->get() + ->map(fn (object $tx): array => [ + 'created_at' => CarbonImmutable::parse($tx->created_at)->toIso8601String(), + 'type' => $tx->type, + 'amount_rub' => (string) $tx->amount_rub, + 'description' => $tx->description, + ]) + ->all(); + + return response()->json([ + 'profile' => [ + 'organization_name' => $tenant->organization_name, + 'contact_email' => $tenant->contact_email, + 'contact_name' => $tenant->contact_name, + 'contact_phone' => $tenant->contact_phone, + 'inn' => $tenant->inn, + 'subject_type' => $tenant->subject_type, + 'created_at' => $tenant->created_at !== null + ? CarbonImmutable::parse($tenant->created_at)->toIso8601String() + : null, + 'desired_daily_numbers' => $tenant->desired_daily_numbers, + 'last_activity_at' => $tenant->last_activity_at !== null + ? CarbonImmutable::parse($tenant->last_activity_at)->toIso8601String() + : null, + ], + 'kpi' => [ + 'balance_rub' => (string) $tenant->balance_rub, + 'runway_days' => $runwayDays, + 'projects_count' => $projectsCount, + 'leads_delivered' => $leadsDelivered, + 'leads_target' => $leadsTarget, + 'avg_lead_price_rub' => $avgLeadPriceRub, + 'earned_rub' => null, // Phase 3: tariff engine + ], + 'projects' => $projects, + 'leads_by_day' => $leadsByDayFormatted, + 'recent_leads' => $recentLeads, + 'activity' => $activity, + ]); + } + + /** + * Маска телефона по 152-ФЗ: видны первые 2 цифры и 2 последних. + * + * Пример: «79161234567» → «79** *** ** 67» + * + * Зеркало AdminLeadsController::maskPhone — единый подход к маскированию ПДн. + */ + private function maskPhone(?string $phone): string + { + if (! $phone) { + return '—'; + } + $digits = preg_replace('/\D/', '', $phone); + if (strlen((string) $digits) < 4) { + return '***'; + } + $last2 = substr((string) $digits, -2); + $first = substr((string) $digits, 0, 2); + + return $first.'** *** ** '.$last2; + } } diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 4666d2da..65aeb659 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3312,3 +3312,18 @@ parameters: identifier: method.notFound count: 1 path: tests/Feature/Billing/InvoiceCreateTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesClientCardTest.php + - + message: '#^Access to an undefined property Pest\\Mixins\\Expectation\\:\:\$not\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Sales/SalesClientCardTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesClientCardTest.php diff --git a/app/routes/web.php b/app/routes/web.php index 931da60a..25c1b70b 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -243,6 +243,7 @@ Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () { // Зона данных портала (наполняется в Фазах 1–7). Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () { Route::get('/clients', [SalesClientsController::class, 'index']); + Route::get('/clients/{tenantId}', [SalesClientsController::class, 'show'])->whereNumber('tenantId'); // attachments, income, tariffs, payouts, invoices, managers, dashboard }); diff --git a/app/tests/Feature/Sales/SalesClientCardTest.php b/app/tests/Feature/Sales/SalesClientCardTest.php new file mode 100644 index 00000000..a5d791ed --- /dev/null +++ b/app/tests/Feature/Sales/SalesClientCardTest.php @@ -0,0 +1,298 @@ + ucfirst($role).' '.uniqid(), + 'email' => 'card-test-'.$role.uniqid().'@test.local', + 'password' => Hash::make('secret'), + 'role' => $role, + 'is_active' => true, + ]); +} + +function card_getClientCard(SalesUser $user, int $tenantId, array $params = []): TestResponse +{ + $token = $user->createToken('sales')->plainTextToken; + $url = '/api/sales/clients/'.$tenantId; + if ($params !== []) { + $url .= '?'.http_build_query($params); + } + + return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url); +} + +/** + * Создаёт тенанта с реквизитами. + */ +function card_makeTenant(string $orgName = 'Тест-Орг', float $balance = 1000.0): Tenant +{ + $tenant = Tenant::factory()->create([ + 'organization_name' => $orgName, + 'status' => 'active', + 'is_trial' => false, + 'balance_rub' => (string) $balance, + 'chargeback_unrecovered_rub' => '0.00', + 'desired_daily_numbers' => 5, + 'contact_email' => 'contact@test.local', + 'last_activity_at' => now(), + ]); + + DB::table('tenant_requisites')->insert([ + 'tenant_id' => $tenant->id, + 'subject_type' => 'legal_entity', + 'contact_name' => 'Иванов Иван Иванович', + 'contact_phone' => '+70001112233', + 'inn' => '1234567890', + 'legal_address' => 'г. Москва, ул. Тестовая, 1', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $tenant; +} + +/** + * Присваивает тенанта менеджеру. + */ +function card_assignTenant(SalesUser $manager, Tenant $tenant): SalesClientAssignment +{ + return SalesClientAssignment::create([ + 'sales_user_id' => $manager->id, + 'tenant_id' => $tenant->id, + 'tariff_id' => null, + 'tariff_kind' => null, + 'tariff_params' => [], + 'assigned_at' => now(), + ]); +} + +/** + * Создаёт проект для тенанта и возвращает его id. + */ +function card_makeProject(int $tenantId): int +{ + // signal_type=NULL пропускает constraint chk_projects_signal_identifier_required + // (constraint требует signal_identifier только для site/call) + return (int) DB::table('projects')->insertGetId([ + 'tenant_id' => $tenantId, + 'name' => 'Тест-Проект '.uniqid(), + 'tag' => 'tag-'.uniqid(), + 'is_active' => true, + 'signal_type' => null, + 'daily_limit_target' => 10, + 'delivery_days_mask' => 127, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +/** + * Вставляет сделку с конкретным телефоном и возвращает её deal_id. + */ +function card_insertDeal(int $tenantId, int $projectId, string $phone, string $receivedAt): int +{ + return (int) DB::table('deals')->insertGetId([ + 'tenant_id' => $tenantId, + 'project_id' => $projectId, + 'source_crm_id' => rand(100_000_000, 999_999_999), + 'phone' => $phone, + 'status' => 'new', + 'is_test' => false, + 'received_at' => $receivedAt, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +/** + * Вставляет запись lead_charges для тенанта. + * + * @param int $dealId — id сделки (NOT NULL в схеме) + * @param string $dealReceivedAt — received_at сделки (NOT NULL, composite FK) + */ +function card_insertCharge(int $tenantId, int $priceKopecks, string $chargedAt, int $dealId, string $dealReceivedAt): void +{ + DB::table('lead_charges')->insert([ + 'tenant_id' => $tenantId, + 'deal_id' => $dealId, + 'deal_received_at' => $dealReceivedAt, + 'tier_no' => 1, + 'price_per_lead_kopecks' => $priceKopecks, + 'charge_source' => 'rub', + 'charged_at' => $chargedAt, + ]); +} + +/** + * Вставляет баланс-транзакцию. + */ +function card_insertTransaction(int $tenantId, string $type, float $amountRub, string $description = ''): void +{ + DB::table('balance_transactions')->insert([ + 'tenant_id' => $tenantId, + 'type' => $type, + 'amount_rub' => $amountRub, + 'description' => $description, + 'created_at' => now(), + ]); +} + +// ── тесты ──────────────────────────────────────────────────────────────────── + +test('менеджер открывает своего клиента → 200, profile и kpi присутствуют', function () { + $manager = card_makeSalesUser('manager'); + $tenant = card_makeTenant('ООО Тест-Клиент', 5000.0); + card_assignTenant($manager, $tenant); + + $response = card_getClientCard($manager, $tenant->id); + + $response->assertOk(); + + expect($response->json('profile.organization_name'))->toBe('ООО Тест-Клиент'); + expect($response->json('kpi.balance_rub'))->not->toBeNull(); + expect($response->json('kpi.earned_rub'))->toBeNull(); +}); + +test('менеджер открывает чужого клиента → 403', function () { + $manager = card_makeSalesUser('manager'); + $manager2 = card_makeSalesUser('manager'); + $tenant = card_makeTenant('Чужой Клиент'); + card_assignTenant($manager2, $tenant); // назначен другому менеджеру + + $response = card_getClientCard($manager, $tenant->id); + + $response->assertForbidden(); +}); + +test('начальник открывает любого клиента → 200', function () { + $head = card_makeSalesUser('head'); + $manager = card_makeSalesUser('manager'); + $tenant = card_makeTenant('Клиент Начальника'); + card_assignTenant($manager, $tenant); + + $response = card_getClientCard($head, $tenant->id); + + $response->assertOk(); + expect($response->json('profile.organization_name'))->toBe('Клиент Начальника'); +}); + +test('recent_leads — телефоны замаскированы', function () { + $manager = card_makeSalesUser('manager'); + $tenant = card_makeTenant('Клиент Маска'); + card_assignTenant($manager, $tenant); + + $projectId = card_makeProject($tenant->id); + $rawPhone = '79161234567'; + $now = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s'); + card_insertDeal($tenant->id, $projectId, $rawPhone, $now); + + $response = card_getClientCard($manager, $tenant->id); + $response->assertOk(); + + $leads = $response->json('recent_leads'); + expect($leads)->toBeArray()->not->toBeEmpty(); + + // Маска: «79** *** ** 67» (первые 2 цифры + ** *** ** + 2 последних) + // Сырой телефон НЕ должен быть виден + $phone = $leads[0]['phone_masked']; + expect($phone)->not->toBe($rawPhone); + expect($phone)->toContain('**'); +}); + +test('kpi содержит leads_delivered, avg_lead_price_rub, earned_rub=null', function () { + $manager = card_makeSalesUser('manager'); + $tenant = card_makeTenant('Клиент KPI', 2000.0); + card_assignTenant($manager, $tenant); + + $projectId = card_makeProject($tenant->id); + $now = CarbonImmutable::now('Europe/Moscow'); + $inPeriod = $now->format('Y-m-d H:i:s'); + + $dealId = card_insertDeal($tenant->id, $projectId, '79001112233', $inPeriod); + card_insertCharge($tenant->id, 2500, $inPeriod, $dealId, $inPeriod); // 25.00 руб за лид + + $response = card_getClientCard($manager, $tenant->id); + $response->assertOk(); + + expect($response->json('kpi.leads_delivered'))->toBe(1); + // avg_lead_price_rub = 2500 kopecks / 100 / 1 лид = 25 руб + expect((float) $response->json('kpi.avg_lead_price_rub'))->toBe(25.0); + expect($response->json('kpi.earned_rub'))->toBeNull(); +}); + +test('projects содержит проект тенанта', function () { + $manager = card_makeSalesUser('manager'); + $tenant = card_makeTenant('Клиент Проекты'); + card_assignTenant($manager, $tenant); + + card_makeProject($tenant->id); + + $response = card_getClientCard($manager, $tenant->id); + $response->assertOk(); + + $projects = $response->json('projects'); + expect($projects)->toBeArray()->not->toBeEmpty(); + expect($projects[0])->toHaveKey('id'); + expect($projects[0])->toHaveKey('name'); + expect($projects[0])->toHaveKey('signal_type'); + expect($projects[0])->toHaveKey('daily_limit_target'); + expect($projects[0])->toHaveKey('delivered_today'); + expect($projects[0])->toHaveKey('status'); +}); + +test('activity содержит balance_transactions', function () { + $manager = card_makeSalesUser('manager'); + $tenant = card_makeTenant('Клиент Актив', 1500.0); + card_assignTenant($manager, $tenant); + + card_insertTransaction($tenant->id, 'topup', 500.0, 'Тестовое пополнение'); + + $response = card_getClientCard($manager, $tenant->id); + $response->assertOk(); + + $activity = $response->json('activity'); + expect($activity)->toBeArray()->not->toBeEmpty(); + + $tx = $activity[0]; + expect($tx)->toHaveKey('type'); + expect($tx)->toHaveKey('amount_rub'); + expect($tx)->toHaveKey('description'); + expect($tx)->toHaveKey('created_at'); +}); + +test('запрос без токена → 401', function () { + test()->getJson('/api/sales/clients/1') + ->assertUnauthorized(); +});