feat(sales): живой доход по клиентам в списке/карточке/сводке (бэк)

Task 3.3a — SalesClientsController наполняет earned_rub через
SalesEarningsService (был null-заглушкой):
- index: доход по каждому клиенту за период по снимку тарифа привязки;
  клиент без привязки (начальник видит незакреплённых) → null.
- show (kpi): доход по одному клиенту.
- overview (totals): сумма комиссии по всем привязкам в scope (без оклада).

Тесты Фазы 1 обновлены под реальные значения (percent_oborot 10/20% от
засеянного оборота → 500/5/10000 ₽; привязка без тарифа → 0.0; без
привязки → null). Попутно исправлен ключ params тест-тарифа
(percent→rate, движок читает rate). Sales-набор 132/132, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-02 19:32:00 +03:00
parent bf5639486e
commit 3b8694cd4c
5 changed files with 123 additions and 17 deletions
@@ -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,
@@ -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 () {
@@ -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();
});
+36 -2
View File
@@ -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);
+4 -4
View File
@@ -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-сессий.