ucfirst($role).' '.uniqid(), 'email' => 'ov-test-'.$role.uniqid().'@test.local', 'password' => Hash::make('secret'), 'role' => $role, 'is_active' => true, ]); } /** * @param array $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); });