Files
portal/app/app/Http/Controllers/Api/Sales/SalesClientsController.php
T
Дмитрий ba7edaab7b refactor(sales): единый трейт DerivesTenantStatus (DRY)
Производный статус тенанта (trial>suspended>overdue>active) был скопирован
в 3 контроллерах портала продаж. Вынесен в
App\Http\Controllers\Concerns\DerivesTenantStatus; контроллеры
(Clients/Dashboard/Managers) теперь используют трейт. Поведение не
изменено — sales-набор 28/28 затронутых, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:46:39 +03:00

567 lines
24 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);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\DerivesTenantStatus;
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
use App\Http\Controllers\Controller;
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodResolver;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж — экран «Мои клиенты» + карточка клиента + сводка.
*
* GET /api/sales/clients — список (Task 1.3)
* GET /api/sales/clients/{tenantId} — карточка (Task 1.4)
* GET /api/sales/overview — сводка менеджера (Task 1.5)
*
* Менеджер видит только своих клиентов (через ScopesSalesOwnership);
* начальник (role=head) видит всех.
*
* Параметры периода (оба метода):
* ?period=this|prev|prev2|custom (default: this)
* ?from=YYYY-MM-DD (только для period=custom)
* ?to=YYYY-MM-DD (только для period=custom)
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3, Task 1.4, Task 1.5)
*/
class SalesClientsController extends Controller
{
use DerivesTenantStatus;
use ScopesSalesOwnership;
/**
* Список клиентов с метриками периода.
*/
public function index(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
$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;
}
}