feat(sales): результативность менеджеров (бэк)
Task 6.2a — SalesManagersController@performance, только head. По каждому менеджеру за период: клиентов, активных клиентов (derived status), лидов пришло, оборот клиентов, выплачено всего, заработал (forManager), статус (active/vacation по is_active). Фильтр search по имени. Тесты 4/4, sales-набор 166/166, Larastan 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SalesUser;
|
||||
use App\Services\Sales\SalesEarningsService;
|
||||
use App\Services\Sales\SalesMetricsService;
|
||||
use App\Services\Sales\SalesPeriodResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Портал продаж — результативность менеджеров «Дашборд начальника» (Task 6.2a, бэк).
|
||||
*
|
||||
* GET /api/sales/managers/performance — строка на каждого менеджера (role=manager)
|
||||
* за период с метриками и производным статусом.
|
||||
*
|
||||
* Метод — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
|
||||
*
|
||||
* По каждому менеджеру:
|
||||
* clients_count — число привязок (закреплённых клиентов);
|
||||
* active_clients — из них с производным статусом 'active';
|
||||
* leads_delivered — Σ доставленных лидов по клиентам за период;
|
||||
* oborot_rub — Σ оборота по клиентам за период (kopecks-точно внутри сервиса);
|
||||
* paid_all_time_rub — Σ всех выплат менеджера (без ограничения периодом);
|
||||
* earned_rub — forManager(period) = оклад × месяцы + Σ комиссий по снимку тарифа;
|
||||
* status — is_active ? 'active' : 'vacation'.
|
||||
*
|
||||
* Опц. фильтр ?search=имя — ILIKE по имени менеджера.
|
||||
*
|
||||
* Производный статус клиента зеркалит SalesClientsController::deriveTenantStatus.
|
||||
*
|
||||
* Работает под middleware admin-db (crm_admin_user) через DEFAULT connection.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 6.2a)
|
||||
*/
|
||||
class SalesManagersController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/sales/managers/performance — результативность менеджеров (только head).
|
||||
*/
|
||||
public function performance(Request $request): JsonResponse
|
||||
{
|
||||
if (($resp = $this->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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SalesClientAssignment;
|
||||
use App\Models\SalesPayout;
|
||||
use App\Models\SalesUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Sales\SalesEarningsService;
|
||||
use App\Services\Sales\SalesPeriodRange;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* TDD: SalesManagersController@performance — результативность каждого менеджера (Task 6.2a, бэк).
|
||||
*
|
||||
* GET /api/sales/managers/performance — строка на каждого менеджера (role=manager) за период.
|
||||
*
|
||||
* Метод — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
|
||||
*
|
||||
* Для каждого менеджера: число клиентов, из них активных (derived-статус 'active'),
|
||||
* Σ лидов/оборота за период, Σ всех выплат, доход forManager(period), статус active/vacation.
|
||||
*
|
||||
* Аутентификация: $this->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<string,mixed> $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();
|
||||
});
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user