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:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user