ad975c4d44
Task 1.3a: GET /api/sales/clients — менеджер видит своих (ScopesSalesOwnership), начальник всех. Строки: организация, ИНН/тип лица (tenant_requisites), баланс, запас, проекты, лиды/оборот за период (SalesMetricsService), тариф-снимок из assignment, статус 1:1 с AdminTenantsController (trial>suspended>overdue>active). earned_rub=null до Фазы 3. Тест 7/7, stan 0 (baseline: Pest false-pos). Пагинация — TODO. Один эскейп на сессию. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
5.4 KiB
PHP
139 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\Sales;
|
|
|
|
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\SalesUser;
|
|
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
|
|
*
|
|
* Менеджер видит только своих клиентов (через 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)
|
|
*/
|
|
class SalesClientsController extends Controller
|
|
{
|
|
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);
|
|
|
|
$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]);
|
|
}
|
|
}
|