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); $earnings = app(SalesEarningsService::class); // Снимки тарифов привязок для отображаемых клиентов — доход по каждому. // Клиент без привязки (начальник видит и незакреплённых) → earned_rub = null. $assignmentsByTenant = SalesClientAssignment::query() ->whereIn('tenant_id', $rows->pluck('tenant_id')->all()) ->get() ->keyBy('tenant_id'); $data = $rows->map(function (object $row) use ($metrics, $earnings, $period, $assignmentsByTenant): array { $tenantId = (int) $row->tenant_id; $assignment = $assignmentsByTenant->get($tenantId); // 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-логику. $derivedStatus = $this->deriveTenantStatus($row); 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' => $assignment !== null ? round($earnings->forAssignment($assignment, $period), 2) : null, ]; })->all(); return response()->json(['data' => $data]); } /** * Сводка менеджера / начальника. * * GET /api/sales/overview * * Менеджер видит агрегаты только по своим клиентам. * Начальник (role=head) видит агрегаты по всем клиентам. * * Ответ: * totals — агрегаты по клиентам за период * attention — клиенты, требующие внимания (overdue / suspended / runway=0), до 10, worst-first * top_clients — топ-4 клиента по leads_delivered за период, desc * * Производный статус (зеркалит index / AdminTenantsController): * is_trial=true → trial * status=suspended → suspended * balance<0 / chargeback>0 → overdue * status=active → active * * Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.5) */ public function overview(Request $request): JsonResponse { /** @var SalesUser $user */ $user = $request->user('sales'); // 1. Период $period = app(SalesPeriodResolver::class)->resolve([ 'kind' => $request->query('period', 'this'), 'from' => $request->query('from'), 'to' => $request->query('to'), ]); // 2. Tenant scope (null = начальник = без ограничения) $ids = $this->ownedTenantIds($user); // 3. Базовый запрос тенантов $query = DB::table('tenants') ->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', ]); if ($ids !== null) { $query->whereIn('tenants.id', $ids === [] ? [-1] : $ids); } $rows = $query->get(); if ($rows->isEmpty()) { return response()->json([ 'totals' => [ 'clients_count' => 0, 'active' => 0, 'trial' => 0, 'overdue' => 0, 'balance_sum_rub' => '0.00', 'leads_delivered' => 0, 'oborot_rub' => 0.0, 'earned_rub' => 0.0, ], 'attention' => [], 'top_clients' => [], ]); } $metrics = app(SalesMetricsService::class); // Суммарный доход (комиссия) по всем привязкам в scope — без оклада менеджера. $earnings = app(SalesEarningsService::class); $earnedSum = 0.0; foreach ( SalesClientAssignment::query() ->whereIn('tenant_id', $rows->pluck('tenant_id')->all()) ->get() as $assignment ) { $earnedSum += $earnings->forAssignment($assignment, $period); } // 4. Обогащаем строки: derived status + метрики периода $enriched = $rows->map(function (object $row) use ($metrics, $period): array { $tenantId = (int) $row->tenant_id; $derivedStatus = $this->deriveTenantStatus($row); $leadsDelivered = $metrics->leadsDelivered($tenantId, $period); $oborotRub = $metrics->oborotRub($tenantId, $period); $runwayDays = $metrics->runwayDays($tenantId); return [ 'tenant_id' => $tenantId, 'organization_name' => $row->organization_name, 'status' => $derivedStatus, 'balance_rub' => (string) $row->balance_rub, 'leads_delivered' => $leadsDelivered, 'oborot_rub' => $oborotRub, 'runway_days' => $runwayDays, ]; }); // 5. Агрегаты totals $clientsCount = $enriched->count(); $activeCount = $enriched->where('status', 'active')->count(); $trialCount = $enriched->where('status', 'trial')->count(); $overdueCount = $enriched->where('status', 'overdue')->count(); // balance_sum_rub — суммируем как float, возвращаем строкой (как balance_rub у тенанта) $balanceSum = $enriched->sum(fn (array $r): float => (float) $r['balance_rub']); $totalLeads = (int) $enriched->sum('leads_delivered'); $totalOborot = (float) $enriched->sum('oborot_rub'); // 6. attention — overdue / suspended / runway_days === 0; worst-first; ≤10 // runway_days === 0 только для active/trial (у overdue/suspended уже плохо). // runway_days === null означает «нет активных проектов» — НЕ попадает в attention. // Приоритет: overdue > suspended > runway=0 $attentionRows = $enriched->filter( fn (array $r): bool => in_array($r['status'], ['overdue', 'suspended'], true) || ($r['runway_days'] === 0 && in_array($r['status'], ['active', 'trial'], true)), ); // Сортировка worst-first: overdue → suspended → runway=0 → balance desc $statusPriority = ['overdue' => 0, 'suspended' => 1]; $attentionSorted = $attentionRows->sortBy([ fn (array $a, array $b): int => ($statusPriority[$a['status']] ?? 2) <=> ($statusPriority[$b['status']] ?? 2), fn (array $a, array $b): int => (float) $a['balance_rub'] <=> (float) $b['balance_rub'], ])->values()->take(10); $attention = $attentionSorted->map(fn (array $r): array => [ 'tenant_id' => $r['tenant_id'], 'organization_name' => $r['organization_name'], 'status' => $r['status'], 'balance_rub' => $r['balance_rub'], 'runway_days' => $r['runway_days'], ])->values()->all(); // 7. top_clients — топ-4 по leads_delivered за период, desc $topClients = $enriched ->sortByDesc('leads_delivered') ->take(4) ->values() ->map(fn (array $r): array => [ 'tenant_id' => $r['tenant_id'], 'organization_name' => $r['organization_name'], 'leads_delivered' => $r['leads_delivered'], 'oborot_rub' => $r['oborot_rub'], ]) ->values() ->all(); return response()->json([ 'totals' => [ 'clients_count' => $clientsCount, 'active' => $activeCount, 'trial' => $trialCount, 'overdue' => $overdueCount, 'balance_sum_rub' => number_format($balanceSum, 2, '.', ''), 'leads_delivered' => $totalLeads, 'oborot_rub' => $totalOborot, 'earned_rub' => round($earnedSum, 2), ], 'attention' => $attention, 'top_clients' => $topClients, ]); } /** * Карточка клиента. * * 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); // Доход менеджера с этого клиента за период — по снимку тарифа привязки. // Клиент без привязки (начальник открыл незакреплённого) → null. $assignment = SalesClientAssignment::query()->where('tenant_id', $tenantId)->first(); $earnedRub = $assignment !== null ? round(app(SalesEarningsService::class)->forAssignment($assignment, $period), 2) : null; // 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' => $earnedRub, ], '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; } }