diff --git a/app/app/Http/Controllers/Api/Sales/SalesClientsController.php b/app/app/Http/Controllers/Api/Sales/SalesClientsController.php index c7cac9f4..3340c0aa 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesClientsController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesClientsController.php @@ -6,7 +6,9 @@ namespace App\Http\Controllers\Api\Sales; 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; @@ -93,10 +95,20 @@ class SalesClientsController extends Controller ->get(); $metrics = app(SalesMetricsService::class); + $earnings = app(SalesEarningsService::class); - $data = $rows->map(function (object $row) use ($metrics, $period): array { + // Снимки тарифов привязок для отображаемых клиентов — доход по каждому. + // Клиент без привязки (начальник видит и незакреплённых) → 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') @@ -121,7 +133,9 @@ class SalesClientsController extends Controller 'runway_days' => $metrics->runwayDays($tenantId), 'leads_delivered' => $metrics->leadsDelivered($tenantId, $period), 'oborot_rub' => $metrics->oborotRub($tenantId, $period), - 'earned_rub' => null, // Phase 3: tariff engine + 'earned_rub' => $assignment !== null + ? round($earnings->forAssignment($assignment, $period), 2) + : null, ]; })->all(); @@ -192,7 +206,7 @@ class SalesClientsController extends Controller 'balance_sum_rub' => '0.00', 'leads_delivered' => 0, 'oborot_rub' => 0.0, - 'earned_rub' => null, + 'earned_rub' => 0.0, ], 'attention' => [], 'top_clients' => [], @@ -201,6 +215,17 @@ class SalesClientsController extends Controller $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; @@ -281,7 +306,7 @@ class SalesClientsController extends Controller 'balance_sum_rub' => number_format($balanceSum, 2, '.', ''), 'leads_delivered' => $totalLeads, 'oborot_rub' => $totalOborot, - 'earned_rub' => null, + 'earned_rub' => round($earnedSum, 2), ], 'attention' => $attention, 'top_clients' => $topClients, @@ -356,6 +381,13 @@ class SalesClientsController extends Controller $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) @@ -499,7 +531,7 @@ class SalesClientsController extends Controller 'leads_delivered' => $leadsDelivered, 'leads_target' => $leadsTarget, 'avg_lead_price_rub' => $avgLeadPriceRub, - 'earned_rub' => null, // Phase 3: tariff engine + 'earned_rub' => $earnedRub, ], 'projects' => $projects, 'leads_by_day' => $leadsByDayFormatted, diff --git a/app/tests/Feature/Sales/SalesClientCardTest.php b/app/tests/Feature/Sales/SalesClientCardTest.php index a5d791ed..b1aace1c 100644 --- a/app/tests/Feature/Sales/SalesClientCardTest.php +++ b/app/tests/Feature/Sales/SalesClientCardTest.php @@ -182,7 +182,8 @@ test('менеджер открывает своего клиента → 200, p expect($response->json('profile.organization_name'))->toBe('ООО Тест-Клиент'); expect($response->json('kpi.balance_rub'))->not->toBeNull(); - expect($response->json('kpi.earned_rub'))->toBeNull(); + // Привязка без тарифа (tariff_kind=null) → доход 0.0. + expect((float) $response->json('kpi.earned_rub'))->toBe(0.0); }); test('менеджер открывает чужого клиента → 403', function () { @@ -231,10 +232,19 @@ test('recent_leads — телефоны замаскированы', function () expect($phone)->toContain('**'); }); -test('kpi содержит leads_delivered, avg_lead_price_rub, earned_rub=null', function () { +test('kpi содержит leads_delivered, avg_lead_price_rub, earned_rub по тарифу', function () { $manager = card_makeSalesUser('manager'); $tenant = card_makeTenant('Клиент KPI', 2000.0); - card_assignTenant($manager, $tenant); + + // Привязка со снимком тарифа percent_oborot 20% → доход = оборот × 20%. + SalesClientAssignment::create([ + 'sales_user_id' => $manager->id, + 'tenant_id' => $tenant->id, + 'tariff_id' => null, + 'tariff_kind' => 'percent_oborot', + 'tariff_params' => ['rate' => 20], + 'assigned_at' => now(), + ]); $projectId = card_makeProject($tenant->id); $now = CarbonImmutable::now('Europe/Moscow'); @@ -249,7 +259,8 @@ test('kpi содержит leads_delivered, avg_lead_price_rub, earned_rub=null' expect($response->json('kpi.leads_delivered'))->toBe(1); // avg_lead_price_rub = 2500 kopecks / 100 / 1 лид = 25 руб expect((float) $response->json('kpi.avg_lead_price_rub'))->toBe(25.0); - expect($response->json('kpi.earned_rub'))->toBeNull(); + // earned_rub = оборот 25 ₽ × 20% = 5.0 + expect((float) $response->json('kpi.earned_rub'))->toBe(5.0); }); test('projects содержит проект тенанта', function () { diff --git a/app/tests/Feature/Sales/SalesClientsIndexTest.php b/app/tests/Feature/Sales/SalesClientsIndexTest.php index b2803b51..3e19c3ff 100644 --- a/app/tests/Feature/Sales/SalesClientsIndexTest.php +++ b/app/tests/Feature/Sales/SalesClientsIndexTest.php @@ -203,19 +203,31 @@ test('менеджер без назначений видит 0 клиентов expect($response->json('data'))->toBeArray()->toHaveCount(0); }); -test('строка ответа содержит нужные поля и earned_rub=null', function () { +test('строка ответа содержит нужные поля и earned_rub по тарифу', function () { $manager = clients_makeSalesUser('manager'); $tariff = SalesTariff::create([ 'name' => 'Тариф Тест', 'kind' => 'percent_oborot', - 'params' => ['percent' => 10], + 'params' => ['rate' => 10], 'is_active' => true, ]); $t1 = clients_makeTenantWithInn('301', 'ООО Тест'); clients_assignTenant($manager, $t1, $tariff); + // Оборот текущего месяца: 500 000 копеек = 5 000 ₽ → комиссия 10% = 500 ₽. + DB::table('lead_charges')->insert([ + 'tenant_id' => $t1->id, + 'deal_id' => 1, + 'deal_received_at' => now(), + 'tier_no' => 1, + 'price_per_lead_kopecks' => 500_000, + 'charge_source' => 'rub', + 'charged_at' => now(), + 'created_at' => now(), + ]); + $response = clients_getClients($manager); $response->assertOk(); @@ -237,6 +249,23 @@ test('строка ответа содержит нужные поля и earned expect($row['organization_name'])->toBe('ООО Тест'); expect($row['inn'])->toBe('301'); expect($row['tariff_name'])->toBe('Тариф Тест'); + // earned_rub = оборот 5 000 ₽ × 10% = 500.0 (по снимку тарифа привязки). + // (float) — JSON кодирует целочисленный float как int. + expect((float) $row['earned_rub'])->toBe(500.0); +}); + +test('начальник: клиент без привязки → earned_rub null', function () { + $head = clients_makeSalesUser('head'); + + // Тенант, не закреплённый ни за одним менеджером. + $t = clients_makeTenantWithInn('777', 'Без менеджера'); + + $response = clients_getClients($head); + $response->assertOk(); + + $rows = collect($response->json('data')); + $row = $rows->firstWhere('tenant_id', $t->id); + expect($row)->not->toBeNull(); expect($row['earned_rub'])->toBeNull(); }); diff --git a/app/tests/Feature/Sales/SalesOverviewTest.php b/app/tests/Feature/Sales/SalesOverviewTest.php index 4a6d6cc0..3c1f7ef0 100644 --- a/app/tests/Feature/Sales/SalesOverviewTest.php +++ b/app/tests/Feature/Sales/SalesOverviewTest.php @@ -167,12 +167,45 @@ test('менеджер: totals по 3 своим клиентам — active/tri expect($totals['trial'])->toBe(1); expect($totals['overdue'])->toBeGreaterThanOrEqual(1); // overdue включает должника expect($totals['leads_delivered'])->toBe(2); - expect($totals['earned_rub'])->toBeNull(); + // Привязки без тарифа (tariff_kind=null) → доход 0.0 (не null). + expect((float) $totals['earned_rub'])->toBe(0.0); // balance_sum_rub = 1000 + 200 + (-50) = 1150 expect((float) $totals['balance_sum_rub'])->toBe(1150.0); }); +test('менеджер: totals.earned_rub — сумма комиссии по тарифам клиентов', function () { + $manager = ov_makeSalesUser('manager'); + $tenant = ov_makeTenant('Комиссия', false, 'active', 1000.0); + + // Привязка со снимком тарифа percent_oborot 20%. + SalesClientAssignment::create([ + 'sales_user_id' => $manager->id, + 'tenant_id' => $tenant->id, + 'tariff_id' => null, + 'tariff_kind' => 'percent_oborot', + 'tariff_params' => ['rate' => 20], + 'assigned_at' => now(), + ]); + + // Оборот текущего месяца: 5 000 000 копеек = 50 000 ₽ → комиссия 20% = 10 000 ₽. + DB::table('lead_charges')->insert([ + 'tenant_id' => $tenant->id, + 'deal_id' => 1, + 'deal_received_at' => now(), + 'tier_no' => 1, + 'price_per_lead_kopecks' => 5_000_000, + 'charge_source' => 'rub', + 'charged_at' => now(), + 'created_at' => now(), + ]); + + $response = ov_getOverview($manager, ['period' => 'this']); + $response->assertOk(); + + expect((float) $response->json('totals.earned_rub'))->toBe(10000.0); +}); + test('менеджер: attention содержит overdue-клиента', function () { $manager = ov_makeSalesUser('manager'); @@ -290,7 +323,8 @@ test('менеджер без клиентов: clients_count=0, пустые м expect($totals['trial'])->toBe(0); expect($totals['overdue'])->toBe(0); expect($totals['leads_delivered'])->toBe(0); - expect($totals['earned_rub'])->toBeNull(); + // Нет клиентов → доход 0.0. + expect((float) $totals['earned_rub'])->toBe(0.0); expect($response->json('attention'))->toBeArray()->toHaveCount(0); expect($response->json('top_clients'))->toBeArray()->toHaveCount(0); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 703ed49f..0760d633 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-02T15:57:13.204Z +Last updated: 2026-07-02T16:10:33.665Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 9812 | Code | 9.77ч | 0.0ч | -| 3412 | MsMpEng | 9.46ч | 0.0ч | -| 4 | System | 2.53ч | NaNч | +| 9812 | Code | 9.80ч | NaNч | +| 3412 | MsMpEng | 9.62ч | NaNч | +| 4 | System | 2.56ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.