diff --git a/app/app/Http/Controllers/Api/Sales/SalesManagersController.php b/app/app/Http/Controllers/Api/Sales/SalesManagersController.php new file mode 100644 index 00000000..e288f299 --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesManagersController.php @@ -0,0 +1,154 @@ +denyIfNotHead($request)) !== null) { + return $resp; + } + + $range = app(SalesPeriodResolver::class)->resolve([ + 'kind' => $request->query('period', 'this'), + 'from' => $request->query('from'), + 'to' => $request->query('to'), + ]); + + $metrics = app(SalesMetricsService::class); + $earnings = app(SalesEarningsService::class); + + // Все менеджеры отдела с их привязками (опц. фильтр по имени). + $query = SalesUser::query() + ->where('role', 'manager') + ->with('assignments') + ->orderBy('name'); + + $search = trim((string) $request->query('search', '')); + if ($search !== '') { + $query->where('name', 'ilike', '%'.$search.'%'); + } + + $managers = $query->get(); + + $data = $managers->map(function (SalesUser $manager) use ($metrics, $earnings, $range): array { + $tenantIds = $manager->assignments + ->map(static fn ($a): int => (int) $a->tenant_id) + ->all(); + + $leads = 0; + $oborot = 0.0; + foreach ($tenantIds as $tenantId) { + $leads += $metrics->leadsDelivered($tenantId, $range); + $oborot += $metrics->oborotRub($tenantId, $range); + } + + // active_clients: тенанты привязок с производным статусом 'active'. + // Один запрос на менеджера по его tenant_ids. + $activeClients = 0; + if ($tenantIds !== []) { + $tenantRows = DB::table('tenants') + ->whereIn('id', $tenantIds) + ->whereNull('deleted_at') + ->select(['id', 'status', 'is_trial', 'balance_rub', 'chargeback_unrecovered_rub']) + ->get(); + + foreach ($tenantRows as $row) { + if ($this->deriveTenantStatus($row) === 'active') { + $activeClients++; + } + } + } + + $paidAllTime = (float) $manager->payouts()->sum('amount_rub'); + + return [ + 'manager_id' => $manager->id, + 'name' => $manager->name, + 'email' => $manager->email, + 'clients_count' => $manager->assignments->count(), + 'active_clients' => $activeClients, + 'leads_delivered' => $leads, + 'oborot_rub' => round($oborot, 2), + 'paid_all_time_rub' => round($paidAllTime, 2), + 'earned_rub' => round($earnings->forManager($manager, $range), 2), + 'status' => $manager->is_active ? 'active' : 'vacation', + ]; + })->values()->all(); + + return response()->json(['data' => $data]); + } + + // ── private ────────────────────────────────────────────────────────────── + + /** + * Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть. + */ + private function denyIfNotHead(Request $request): ?JsonResponse + { + /** @var SalesUser $user */ + $user = $request->user('sales'); + + if (! $user->isHead()) { + return response()->json(['message' => 'Доступно только начальнику отдела.'], 403); + } + + return null; + } + + /** + * Производный статус тенанта — зеркалит SalesClientsController::deriveTenantStatus. + * + * Приоритет: trial > suspended > overdue (balance<0 | chargeback>0) > active > raw status. + */ + private function deriveTenantStatus(object $row): string + { + return 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, + }; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 97baa449..f7651f0a 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -3422,3 +3422,13 @@ parameters: identifier: method.notFound count: 1 path: tests/Feature/Sales/SalesDashboardTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 3 + path: tests/Feature/Sales/SalesPerformanceTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesPerformanceTest.php diff --git a/app/routes/web.php b/app/routes/web.php index 29fc044c..a10811fc 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Sales\SalesClientsController; use App\Http\Controllers\Api\Sales\SalesDashboardController; use App\Http\Controllers\Api\Sales\SalesIncomeController; use App\Http\Controllers\Api\Sales\SalesInvoiceController; +use App\Http\Controllers\Api\Sales\SalesManagersController; use App\Http\Controllers\Api\Sales\SalesPayoutController; use App\Http\Controllers\Api\Sales\SalesTariffController; use Illuminate\Support\Facades\Route; @@ -271,6 +272,8 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales Route::post('/invoices/{id}/mark-paid', [SalesInvoiceController::class, 'markPaid'])->whereNumber('id'); // Task 6.1a: сводка отдела «Дашборд начальника» (только head). Route::get('/dashboard/overview', [SalesDashboardController::class, 'overview']); + // Task 6.2a: результативность менеджеров (только head). + Route::get('/managers/performance', [SalesManagersController::class, 'performance']); // managers, dashboard }); diff --git a/app/tests/Feature/Sales/SalesPerformanceTest.php b/app/tests/Feature/Sales/SalesPerformanceTest.php new file mode 100644 index 00000000..4319d7dd --- /dev/null +++ b/app/tests/Feature/Sales/SalesPerformanceTest.php @@ -0,0 +1,269 @@ +actingAs($user, 'sales'). + * Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php). + * DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске. + * + * ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций. + * ⚠️ ПАРТИЦИИ: balance_transactions / lead_charges партиционированы помесячно + * динамически → сеем ТОЛЬКО в текущий месяц. + */ +uses(DatabaseTransactions::class); + +beforeEach(function () { + $now = CarbonImmutable::create(null, null, 15, 12, 0, 0, 'Europe/Moscow'); + CarbonImmutable::setTestNow($now); + Carbon::setTestNow($now); +}); + +afterEach(function () { + CarbonImmutable::setTestNow(); + Carbon::setTestNow(); +}); + +// ── helpers (уникальный префикс perf_) ─────────────────────────────────────── + +function perf_head(): SalesUser +{ + return SalesUser::create([ + 'name' => 'Начальник '.uniqid(), + 'email' => 'perfhead'.uniqid().'@sales.local', + 'password' => bcrypt('secret'), + 'role' => 'head', + 'is_active' => true, + ]); +} + +function perf_manager(string $name, bool $isActive = true, float $baseSalary = 0.0): SalesUser +{ + return SalesUser::create([ + 'name' => $name, + 'email' => 'perfmgr'.uniqid().'@sales.local', + 'password' => bcrypt('secret'), + 'role' => 'manager', + 'is_active' => $isActive, + 'base_salary_rub' => $baseSalary, + ]); +} + +/** Активный клиент: is_trial=false, status=active, баланс≥0, chargeback=0. */ +function perf_activeTenant(): Tenant +{ + return Tenant::factory()->create([ + 'is_trial' => false, + 'status' => 'active', + 'balance_rub' => 1000.0, + 'chargeback_unrecovered_rub' => 0.0, + 'delivered_in_month' => 0, + ]); +} + +/** Overdue-клиент: отрицательный баланс → derived-статус 'overdue'. */ +function perf_overdueTenant(): Tenant +{ + return Tenant::factory()->create([ + 'is_trial' => false, + 'status' => 'active', + 'balance_rub' => -500.0, + 'chargeback_unrecovered_rub' => 0.0, + 'delivered_in_month' => 0, + ]); +} + +/** @param array $params */ +function perf_assignment(SalesUser $user, Tenant $tenant, string $kind, array $params): SalesClientAssignment +{ + return SalesClientAssignment::create([ + 'sales_user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'tariff_kind' => $kind, + 'tariff_params' => $params, + 'assigned_at' => Carbon::now('Europe/Moscow')->subMonthsNoOverflow(2)->subDays(15), + ]); +} + +/** Дата в ТЕКУЩЕМ месяце МСК (день $day, только Y-m-d). */ +function perf_date(int $day): string +{ + return CarbonImmutable::now('Europe/Moscow')->startOfMonth() + ->setDay($day)->format('Y-m-d'); +} + +function perf_leadCharge(int $tenantId, int $kopecks, int $day = 10): void +{ + $dt = perf_date($day).' 10:00:00'; + DB::table('lead_charges')->insert([ + 'tenant_id' => $tenantId, + 'deal_id' => fake()->numberBetween(1, 99999), + 'deal_received_at' => $dt, + 'tier_no' => 1, + 'price_per_lead_kopecks' => $kopecks, + 'charge_source' => 'rub', + 'charged_at' => $dt, + 'created_at' => $dt, + ]); +} + +function perf_project(int $tenantId): int +{ + return (int) DB::table('projects')->insertGetId([ + 'tenant_id' => $tenantId, + 'name' => 'Проект '.uniqid(), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function perf_deal(int $tenantId, int $projectId, int $day = 10): void +{ + $dt = perf_date($day).' 10:00:00'; + DB::table('deals')->insert([ + 'tenant_id' => $tenantId, + 'project_id' => $projectId, + 'phone' => '7916'.fake()->numerify('#######'), + 'status' => 'new', + 'received_at' => $dt, + 'is_test' => false, + 'created_at' => $dt, + 'updated_at' => $dt, + ]); +} + +function perf_payout(int $managerId, int $headId, string $amount, int $day = 12): SalesPayout +{ + return SalesPayout::create([ + 'sales_user_id' => $managerId, + 'amount_rub' => $amount, + 'paid_on' => perf_date($day), + 'created_by' => $headId, + ]); +} + +/** Свежий сервис дохода — для ручной сверки earned_rub. */ +function perf_thisMonthRange(): SalesPeriodRange +{ + $start = CarbonImmutable::now('Europe/Moscow')->startOfMonth(); + + return new SalesPeriodRange( + $start->startOfDay(), + $start->endOfMonth()->setTime(23, 59, 59), + ); +} + +// ── 1. доступ ──────────────────────────────────────────────────────────────── + +test('менеджер GET /api/sales/managers/performance → 403', function () { + $manager = perf_manager('Менеджер '.uniqid()); + + $this->actingAs($manager, 'sales')->getJson('/api/sales/managers/performance') + ->assertStatus(403) + ->assertJson(['message' => 'Доступно только начальнику отдела.']); +}); + +// ── 2. happy path (head) ───────────────────────────────────────────────────── + +test('head: строка на каждого менеджера с корректными метриками и статусом', function () { + $head = perf_head(); + + // Менеджер 1 (active, оклад 30 000), 2 клиента: active + overdue. + $m1 = perf_manager('Алексей Первый', isActive: true, baseSalary: 30000.0); + $tActive = perf_activeTenant(); + $tOverdue = perf_overdueTenant(); + + // percent_oborot 20% от оборота active-клиента 50 000 ₽ → комиссия 10 000. + perf_assignment($m1, $tActive, 'percent_oborot', ['rate' => 20]); + perf_assignment($m1, $tOverdue, 'percent_oborot', ['rate' => 20]); + + perf_leadCharge($tActive->id, 5_000_000); // 50 000 ₽ + $pActive = perf_project($tActive->id); + $pOverdue = perf_project($tOverdue->id); + perf_deal($tActive->id, $pActive); + perf_deal($tActive->id, $pActive); // 2 лида на active-клиенте + perf_deal($tOverdue->id, $pOverdue); // 1 лид на overdue-клиенте + + // Одна выплата (paid_all_time). + perf_payout($m1->id, $head->id, '7000.00'); + + // Менеджер 2 (is_active=false → vacation), без клиентов. + $m2 = perf_manager('Борис Второй', isActive: false, baseSalary: 25000.0); + + $response = $this->actingAs($head, 'sales')->getJson('/api/sales/managers/performance'); + + $response->assertOk(); + $data = collect($response->json('data')); + + $row1 = $data->firstWhere('manager_id', $m1->id); + $row2 = $data->firstWhere('manager_id', $m2->id); + + // earned_rub считаем вручную тем же сервисом (оклад 30 000 + комиссия 10 000 = 40 000). + $earned = round(app(SalesEarningsService::class)->forManager($m1->fresh(), perf_thisMonthRange()), 2); + + expect($row1)->not->toBeNull() + ->and($row1['name'])->toBe('Алексей Первый') + ->and($row1['email'])->toBe($m1->email) + ->and($row1['clients_count'])->toBe(2) + ->and($row1['active_clients'])->toBe(1) + ->and($row1['leads_delivered'])->toBe(3) + ->and((float) $row1['oborot_rub'])->toBe(50000.0) + ->and((float) $row1['paid_all_time_rub'])->toBe(7000.0) + ->and((float) $row1['earned_rub'])->toBe(40000.0) + ->and((float) $row1['earned_rub'])->toBe($earned) + ->and($row1['status'])->toBe('active') + ->and($row1)->toHaveKeys([ + 'manager_id', 'name', 'email', 'clients_count', 'active_clients', + 'leads_delivered', 'oborot_rub', 'paid_all_time_rub', 'earned_rub', 'status', + ]); + + expect($row2)->not->toBeNull() + ->and($row2['clients_count'])->toBe(0) + ->and($row2['active_clients'])->toBe(0) + ->and($row2['status'])->toBe('vacation'); +}); + +// ── 3. search ──────────────────────────────────────────────────────────────── + +test('search по имени менеджера → только совпадающий', function () { + $head = perf_head(); + + $m1 = perf_manager('Уникальный Зевс', isActive: true); + $m2 = perf_manager('Другой Аполлон', isActive: true); + + $data = collect( + $this->actingAs($head, 'sales') + ->getJson('/api/sales/managers/performance?search=Зевс') + ->json('data') + ); + + expect($data->firstWhere('manager_id', $m1->id))->not->toBeNull() + ->and($data->firstWhere('manager_id', $m2->id))->toBeNull(); +}); + +// ── 4. неаутентифицированный ───────────────────────────────────────────────── + +test('неаутентифицированный GET /api/sales/managers/performance → 401', function () { + $this->getJson('/api/sales/managers/performance')->assertUnauthorized(); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index d03ae3c9..48199a28 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-02T17:59:09.064Z +Last updated: 2026-07-02T18:07:21.815Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3412 | MsMpEng | 10.55ч | 0.0ч | -| 9812 | Code | 10.17ч | 220654.0ч | -| 4 | System | 2.75ч | 0.0ч | +| 3412 | MsMpEng | 10.62ч | 0.0ч | +| 9812 | Code | 10.18ч | NaNч | +| 4 | System | 2.77ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.