daa41bcecf
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный оклад daily_salary и процент от пополнений topup_step. Убраны percent_oborot и fix_per_client. Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает FK sales_users.current_tariff_id и sales_client_assignments.tariff_id, удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов возвращает 0 по default-ветке. Снимки привязок не трогаются. Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics, фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта, CHANGELOG схемы v8.61 и спека портала. Не на проде: ветка feat/sales-portal-demo. Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0. Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
456 lines
18 KiB
PHP
456 lines
18 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\SalesClientAssignment;
|
||
use App\Models\SalesUser;
|
||
use App\Models\Tenant;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Illuminate\Testing\TestResponse;
|
||
|
||
/**
|
||
* TDD: GET /api/sales/overview — «Сводка менеджера».
|
||
*
|
||
* Покрывает Task 1.5 портала продаж:
|
||
* - Менеджер видит агрегаты только по СВОИМ клиентам.
|
||
* - Начальник видит агрегаты по ВСЕМ клиентам.
|
||
* - Менеджер без клиентов → clients_count=0, пустые массивы.
|
||
* - totals: clients_count, active/trial/overdue, balance_sum_rub, leads_delivered, oborot_rub, earned_rub=null.
|
||
* - attention: клиенты с overdue/suspended/runway_days=0, отсортированы worst-first, ≤10.
|
||
* - top_clients: топ-4 по leads_delivered за период, desc.
|
||
*
|
||
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
|
||
* SharesAdminPdo применяется глобально в Pest.php.
|
||
*
|
||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.5)
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function ov_makeSalesUser(string $role = 'manager'): SalesUser
|
||
{
|
||
return SalesUser::create([
|
||
'name' => ucfirst($role).' '.uniqid(),
|
||
'email' => 'ov-test-'.$role.uniqid().'@test.local',
|
||
'password' => Hash::make('secret'),
|
||
'role' => $role,
|
||
'is_active' => true,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $params
|
||
*/
|
||
function ov_getOverview(SalesUser $user, array $params = []): TestResponse
|
||
{
|
||
$token = $user->createToken('sales')->plainTextToken;
|
||
$url = '/api/sales/overview';
|
||
if ($params !== []) {
|
||
$url .= '?'.http_build_query($params);
|
||
}
|
||
|
||
return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url);
|
||
}
|
||
|
||
function ov_makeTenant(
|
||
string $orgName,
|
||
bool $isTrial = false,
|
||
string $status = 'active',
|
||
float $balance = 500.0,
|
||
float $chargeback = 0.0,
|
||
): Tenant {
|
||
return Tenant::factory()->create([
|
||
'organization_name' => $orgName,
|
||
'status' => $status,
|
||
'is_trial' => $isTrial,
|
||
'balance_rub' => (string) $balance,
|
||
'chargeback_unrecovered_rub' => (string) $chargeback,
|
||
]);
|
||
}
|
||
|
||
function ov_assign(SalesUser $manager, Tenant $tenant): SalesClientAssignment
|
||
{
|
||
return SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $tenant->id,
|
||
'tariff_id' => null,
|
||
'tariff_kind' => null,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Вставляет deal (лид) в текущем месяце, чтобы не упасть на отсутствии партиции.
|
||
*/
|
||
function ov_insertDeal(int $tenantId, string $receivedAt): void
|
||
{
|
||
$projectId = (int) DB::table('projects')
|
||
->where('tenant_id', $tenantId)
|
||
->value('id');
|
||
|
||
if ($projectId === 0) {
|
||
$projectId = (int) DB::table('projects')->insertGetId([
|
||
'tenant_id' => $tenantId,
|
||
'name' => 'Overview Test Project',
|
||
'tag' => 'ov-test-'.uniqid(),
|
||
'is_active' => true,
|
||
'daily_limit_target' => 10,
|
||
'delivery_days_mask' => 127,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
DB::table('deals')->insert([
|
||
'tenant_id' => $tenantId,
|
||
'project_id' => $projectId,
|
||
'source_crm_id' => rand(100_000_000, 999_999_999),
|
||
'phone' => '7'.str_pad((string) rand(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
|
||
'status' => 'new',
|
||
'is_test' => false,
|
||
'received_at' => $receivedAt,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Вставляет lead_charge в текущем месяце (чтобы партиция существовала).
|
||
*
|
||
* @param int $kopecks цена лида в копейках
|
||
*/
|
||
function ov_insertCharge(int $tenantId, int $projectId, string $chargedAt, int $kopecks): void
|
||
{
|
||
DB::table('lead_charges')->insert([
|
||
'tenant_id' => $tenantId,
|
||
'project_id' => $projectId,
|
||
'source_crm_deal_id' => rand(100_000_000, 999_999_999),
|
||
'price_per_lead_kopecks' => $kopecks,
|
||
'charged_at' => $chargedAt,
|
||
'created_at' => now(),
|
||
'updated_at' => now(),
|
||
]);
|
||
}
|
||
|
||
// ── тесты ────────────────────────────────────────────────────────────────────
|
||
|
||
test('менеджер: totals по 3 своим клиентам — active/trial/overdue, суммы', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
// active с балансом 1000
|
||
$tActive = ov_makeTenant('Актив', false, 'active', 1000.0);
|
||
// trial
|
||
$tTrial = ov_makeTenant('Триал', true, 'active', 200.0);
|
||
// overdue — balance < 0
|
||
$tOverdue = ov_makeTenant('Должник', false, 'active', -50.0);
|
||
|
||
ov_assign($manager, $tActive);
|
||
ov_assign($manager, $tTrial);
|
||
ov_assign($manager, $tOverdue);
|
||
|
||
// Добавляем 2 лида к активному тенанту в текущем месяце
|
||
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
|
||
ov_insertDeal($tActive->id, $nowStr);
|
||
ov_insertDeal($tActive->id, $nowStr);
|
||
|
||
$response = ov_getOverview($manager, ['period' => 'this']);
|
||
$response->assertOk();
|
||
|
||
$totals = $response->json('totals');
|
||
expect($totals['clients_count'])->toBe(3);
|
||
expect($totals['active'])->toBe(1);
|
||
expect($totals['trial'])->toBe(1);
|
||
expect($totals['overdue'])->toBeGreaterThanOrEqual(1); // overdue включает должника
|
||
expect($totals['leads_delivered'])->toBe(2);
|
||
// Привязки без тарифа (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);
|
||
|
||
// Привязка со снимком тарифа topup_step 20% (порог 0, бонус 0) → доход = пополнения × 20%.
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $tenant->id,
|
||
'tariff_id' => null,
|
||
'tariff_kind' => 'topup_step',
|
||
'tariff_params' => ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]],
|
||
'assigned_at' => now(),
|
||
]);
|
||
|
||
// Оборот текущего месяца: 5 000 000 копеек = 50 000 ₽ (метрика oborot_rub).
|
||
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(),
|
||
]);
|
||
|
||
// Пополнения 50 000 ₽ в текущем месяце → комиссия topup_step 20% = 10 000 ₽.
|
||
DB::table('balance_transactions')->insert([
|
||
'tenant_id' => $tenant->id,
|
||
'type' => 'topup',
|
||
'amount_rub' => '50000.00',
|
||
'amount_leads' => 0,
|
||
'balance_rub_after' => '50000.00',
|
||
'description' => 'test',
|
||
'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');
|
||
|
||
// Overdue: баланс < 0 — всегда в attention
|
||
$tOverdue = ov_makeTenant('Должник', false, 'active', -100.0);
|
||
ov_assign($manager, $tOverdue);
|
||
|
||
$response = ov_getOverview($manager);
|
||
$response->assertOk();
|
||
|
||
$attention = $response->json('attention');
|
||
$attentionIds = array_column($attention, 'tenant_id');
|
||
expect($attentionIds)->toContain($tOverdue->id);
|
||
});
|
||
|
||
test('менеджер: attention содержит suspended-клиента', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
$tSuspended = ov_makeTenant('Приостановлен', false, 'suspended', 0.0);
|
||
ov_assign($manager, $tSuspended);
|
||
|
||
$response = ov_getOverview($manager);
|
||
$response->assertOk();
|
||
|
||
$attention = $response->json('attention');
|
||
$attentionIds = array_column($attention, 'tenant_id');
|
||
expect($attentionIds)->toContain($tSuspended->id);
|
||
});
|
||
|
||
test('менеджер: top_clients — топ-4 по leads_delivered, desc', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
// Создаём 5 клиентов и добавляем им разное кол-во лидов
|
||
$tenants = [];
|
||
for ($i = 0; $i < 5; $i++) {
|
||
$t = ov_makeTenant("Клиент $i");
|
||
ov_assign($manager, $t);
|
||
$tenants[$i] = $t;
|
||
}
|
||
|
||
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
|
||
// Лиды: [10, 7, 5, 3, 1]
|
||
$leadCounts = [10, 7, 5, 3, 1];
|
||
foreach ($leadCounts as $idx => $cnt) {
|
||
for ($j = 0; $j < $cnt; $j++) {
|
||
ov_insertDeal($tenants[$idx]->id, $nowStr);
|
||
}
|
||
}
|
||
|
||
$response = ov_getOverview($manager, ['period' => 'this']);
|
||
$response->assertOk();
|
||
|
||
$top = $response->json('top_clients');
|
||
expect($top)->toHaveCount(4);
|
||
|
||
// Первый — максимальное кол-во лидов (10)
|
||
expect($top[0]['leads_delivered'])->toBe(10);
|
||
expect($top[0]['tenant_id'])->toBe($tenants[0]->id);
|
||
// Убеждаемся в убывании
|
||
for ($i = 0; $i < 3; $i++) {
|
||
expect($top[$i]['leads_delivered'])->toBeGreaterThanOrEqual($top[$i + 1]['leads_delivered']);
|
||
}
|
||
});
|
||
|
||
test('начальник: видит агрегаты по ВСЕМ клиентам, включая незакреплённых', function () {
|
||
$head = ov_makeSalesUser('head');
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
// Три тенанта, один из которых не закреплён ни за кем
|
||
$t1 = ov_makeTenant('Закреплён 1', false, 'active', 100.0);
|
||
$t2 = ov_makeTenant('Закреплён 2', true, 'active', 200.0);
|
||
$t3 = ov_makeTenant('Без менеджера', false, 'active', 300.0);
|
||
|
||
ov_assign($manager, $t1);
|
||
ov_assign($manager, $t2);
|
||
// t3 — не назначен никому
|
||
|
||
$response = ov_getOverview($head);
|
||
$response->assertOk();
|
||
|
||
$totals = $response->json('totals');
|
||
// Начальник должен видеть как минимум эти 3 (может быть больше если есть другие тенанты в тест-БД)
|
||
expect($totals['clients_count'])->toBeGreaterThanOrEqual(3);
|
||
|
||
$attentionAndTop = [
|
||
...(array) $response->json('attention'),
|
||
...(array) $response->json('top_clients'),
|
||
];
|
||
$allIds = array_unique([
|
||
...array_column((array) $response->json('attention'), 'tenant_id'),
|
||
...array_column((array) $response->json('top_clients'), 'tenant_id'),
|
||
]);
|
||
|
||
// Хотя бы один из трёх тенантов должен присутствовать в ответе начальника
|
||
// (либо в attention, либо в top)
|
||
// Более строгая проверка — clients_count включает все 3
|
||
// Проверяем через баланс-сумму (≥600 — все три наших тенанта)
|
||
expect((float) $totals['balance_sum_rub'])->toBeGreaterThanOrEqual(600.0);
|
||
});
|
||
|
||
test('менеджер без клиентов: clients_count=0, пустые массивы', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
// Создаём другого менеджера с тенантом — наш менеджер не должен его видеть
|
||
$otherManager = ov_makeSalesUser('manager');
|
||
$t = ov_makeTenant('Чужой', false, 'active', 500.0);
|
||
ov_assign($otherManager, $t);
|
||
|
||
$response = ov_getOverview($manager);
|
||
$response->assertOk();
|
||
|
||
$totals = $response->json('totals');
|
||
expect($totals['clients_count'])->toBe(0);
|
||
expect($totals['active'])->toBe(0);
|
||
expect($totals['trial'])->toBe(0);
|
||
expect($totals['overdue'])->toBe(0);
|
||
expect($totals['leads_delivered'])->toBe(0);
|
||
// Нет клиентов → доход 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);
|
||
});
|
||
|
||
test('structure: ответ содержит все обязательные ключи', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
$response = ov_getOverview($manager);
|
||
$response->assertOk();
|
||
|
||
$response->assertJsonStructure([
|
||
'totals' => [
|
||
'clients_count',
|
||
'active',
|
||
'trial',
|
||
'overdue',
|
||
'balance_sum_rub',
|
||
'leads_delivered',
|
||
'oborot_rub',
|
||
'earned_rub',
|
||
],
|
||
'attention',
|
||
'top_clients',
|
||
]);
|
||
});
|
||
|
||
test('attention: поля tenant_id, organization_name, status, balance_rub, runway_days', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
$tOverdue = ov_makeTenant('Должник', false, 'active', -200.0);
|
||
ov_assign($manager, $tOverdue);
|
||
|
||
$response = ov_getOverview($manager);
|
||
$response->assertOk();
|
||
|
||
$attention = $response->json('attention');
|
||
expect($attention)->toBeArray()->not->toHaveCount(0);
|
||
|
||
$first = $attention[0];
|
||
expect($first)->toHaveKey('tenant_id');
|
||
expect($first)->toHaveKey('organization_name');
|
||
expect($first)->toHaveKey('status');
|
||
expect($first)->toHaveKey('balance_rub');
|
||
expect($first)->toHaveKey('runway_days');
|
||
});
|
||
|
||
test('top_clients: поля tenant_id, organization_name, leads_delivered, oborot_rub', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
|
||
$t = ov_makeTenant('Топ Клиент');
|
||
ov_assign($manager, $t);
|
||
|
||
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
|
||
ov_insertDeal($t->id, $nowStr);
|
||
|
||
$response = ov_getOverview($manager, ['period' => 'this']);
|
||
$response->assertOk();
|
||
|
||
$top = $response->json('top_clients');
|
||
expect($top)->toBeArray()->not->toHaveCount(0);
|
||
|
||
$first = $top[0];
|
||
expect($first)->toHaveKey('tenant_id');
|
||
expect($first)->toHaveKey('organization_name');
|
||
expect($first)->toHaveKey('leads_delivered');
|
||
expect($first)->toHaveKey('oborot_rub');
|
||
});
|
||
|
||
test('запрос без токена → 401', function () {
|
||
test()->getJson('/api/sales/overview')->assertUnauthorized();
|
||
});
|
||
|
||
// ── Task 6.3: сквозной период ────────────────────────────────────────────────
|
||
|
||
test('период меняет месячные метрики, но НЕ баланс (this vs prev)', function () {
|
||
$manager = ov_makeSalesUser('manager');
|
||
$tenant = ov_makeTenant('Клиент периода', false, 'active', 1000.0);
|
||
ov_assign($manager, $tenant);
|
||
|
||
// lead_charges — реальные колонки схемы (project_id в таблице нет).
|
||
$insertCharge = function (int $tenantId, string $dt, int $kopecks): void {
|
||
DB::table('lead_charges')->insert([
|
||
'tenant_id' => $tenantId,
|
||
'deal_id' => rand(1, 99999),
|
||
'deal_received_at' => $dt,
|
||
'tier_no' => 1,
|
||
'price_per_lead_kopecks' => $kopecks,
|
||
'charge_source' => 'rub',
|
||
'charged_at' => $dt,
|
||
'created_at' => $dt,
|
||
]);
|
||
};
|
||
|
||
// Текущий месяц: 1 лид, оборот 3 000 ₽ (300 000 копеек).
|
||
$thisMonth = CarbonImmutable::now('Europe/Moscow')->startOfMonth()->addDays(4)->format('Y-m-d H:i:s');
|
||
ov_insertDeal($tenant->id, $thisMonth);
|
||
$insertCharge($tenant->id, $thisMonth, 300_000);
|
||
|
||
// Прошлый месяц: 1 лид, оборот 5 000 ₽ (500 000 копеек). Партиции deals/lead_charges — в схеме.
|
||
$prevMonth = CarbonImmutable::now('Europe/Moscow')->subMonth()->startOfMonth()->addDays(4)->format('Y-m-d H:i:s');
|
||
ov_insertDeal($tenant->id, $prevMonth);
|
||
$insertCharge($tenant->id, $prevMonth, 500_000);
|
||
|
||
$this_ = ov_getOverview($manager, ['period' => 'this'])->assertOk()->json('totals');
|
||
$prev = ov_getOverview($manager, ['period' => 'prev'])->assertOk()->json('totals');
|
||
|
||
// Месячные метрики различаются по периоду.
|
||
expect($this_['leads_delivered'])->toBe(1);
|
||
expect($prev['leads_delivered'])->toBe(1);
|
||
expect((float) $this_['oborot_rub'])->toBe(3000.0);
|
||
expect((float) $prev['oborot_rub'])->toBe(5000.0);
|
||
|
||
// Баланс — НЕ зависит от периода (текущее состояние счёта).
|
||
expect((float) $this_['balance_sum_rub'])->toBe(1000.0);
|
||
expect((float) $prev['balance_sum_rub'])->toBe(1000.0);
|
||
});
|