actingAs($user, 'sales'). * Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php). * DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске. * * ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций. * ⚠️ ПАРТИЦИИ: lead_charges / balance_transactions партиционированы помесячно * динамически → сеем оборот/выплаты ТОЛЬКО в текущий месяц. */ uses(DatabaseTransactions::class); beforeEach(function () { $now = CarbonImmutable::create(null, null, 15, 12, 0, 0, 'Europe/Moscow'); CarbonImmutable::setTestNow($now); Carbon::setTestNow($now); }); afterEach(function () { CarbonImmutable::setTestNow(); Carbon::setTestNow(); }); // ── helpers (уникальный префикс dash_) ─────────────────────────────────────── function dash_head(): SalesUser { return SalesUser::create([ 'name' => 'Начальник '.uniqid(), 'email' => 'dashhead'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'head', 'is_active' => true, ]); } function dash_manager(float $baseSalary = 0.0, bool $isActive = true): SalesUser { return SalesUser::create([ 'name' => 'Менеджер '.uniqid(), 'email' => 'dashmgr'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => $isActive, 'base_salary_rub' => $baseSalary, ]); } /** @param array $overrides */ function dash_tenant(array $overrides = []): Tenant { return Tenant::factory()->create(array_merge([ 'balance_rub' => 0.0, 'delivered_in_month' => 0, ], $overrides)); } /** @param array $params */ function dash_assignment(SalesUser $user, Tenant $tenant, string $kind = 'percent_oborot', array $params = ['rate' => 20]): SalesClientAssignment { return SalesClientAssignment::create([ 'sales_user_id' => $user->id, 'tenant_id' => $tenant->id, 'tariff_kind' => $kind, 'tariff_params' => $params, 'assigned_at' => Carbon::now('Europe/Moscow')->subMonthsNoOverflow(2)->subDays(15), ]); } /** Дата в ТЕКУЩЕМ месяце МСК (день $day, только Y-m-d). */ function dash_date(int $day): string { return CarbonImmutable::now('Europe/Moscow')->startOfMonth() ->setDay($day)->format('Y-m-d'); } function dash_leadCharge(int $tenantId, int $kopecks, ?string $at = null): void { $dt = ($at ?? dash_date(10)).' 10:00:00'; DB::table('lead_charges')->insert([ 'tenant_id' => $tenantId, 'deal_id' => fake()->numberBetween(1, 9_999_999), 'deal_received_at' => $dt, 'tier_no' => 1, 'price_per_lead_kopecks' => $kopecks, 'charge_source' => 'rub', 'charged_at' => $dt, 'created_at' => $dt, ]); } /** Пополнение (topup) в ТЕКУЩЕМ месяце МСК — для дохода по тарифу topup_step. */ function dash_topup(int $tenantId, string $amountRub, ?string $at = null): void { $dt = ($at ?? dash_date(10)).' 10:00:00'; DB::table('balance_transactions')->insert([ 'tenant_id' => $tenantId, 'type' => 'topup', 'amount_rub' => $amountRub, 'amount_leads' => 0, 'balance_rub_after' => $amountRub, 'description' => 'test', 'created_at' => $dt, ]); } function dash_payout(int $managerId, int $headId, string $amount, string $paidOn): SalesPayout { return SalesPayout::create([ 'sales_user_id' => $managerId, 'amount_rub' => $amount, 'paid_on' => $paidOn, 'comment' => null, 'created_by' => $headId, ]); } function dash_pendingAttachment(int $managerId): SalesAttachmentRequest { return SalesAttachmentRequest::create([ 'sales_user_id' => $managerId, 'login_input' => 'dash-client-'.uniqid().'@example.com', 'status' => 'pending', ]); } function dash_issuedInvoice(int $tenantId): SaasInvoice { $le = LegalEntity::query()->where('is_default', true)->first() ?? LegalEntity::create([ 'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP', 'inn' => '770000000099', 'is_default' => true, ]); return SaasInvoice::create([ 'tenant_id' => $tenantId, 'legal_entity_id' => $le->id, 'invoice_number' => 'СЧ-2026-'.substr(uniqid(), -6), 'payer_type' => 'legal', 'payer_name' => 'ООО Плательщик', 'payer_inn' => '5000000000', 'amount_net' => '1500.00', 'amount_total' => '1500.00', 'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5), ]); } // ── 1. доступ: менеджер (не head) → 403 ────────────────────────────────────── test('менеджер GET /api/sales/dashboard/overview → 403', function () { $manager = dash_manager(); $this->actingAs($manager, 'sales')->getJson('/api/sales/dashboard/overview') ->assertStatus(403) ->assertJson(['message' => 'Доступно только начальнику отдела.']); }); test('неаутентифицированный GET /api/sales/dashboard/overview → 401', function () { $this->getJson('/api/sales/dashboard/overview')->assertUnauthorized(); }); // ── 2. head: полная сводка ─────────────────────────────────────────────────── test('head: overview отдаёт kpi / alerts / performance по всему отделу', function () { $head = dash_head(); // Менеджер А (активный) — 2 клиента с оборотом и пополнениями. Тариф topup_step 20%. // Менеджеры без текущего тарифа → оклад не начисляется; доход = комиссия по снимкам. $mgrA = dash_manager(baseSalary: 30000.0, isActive: true); $tA1 = dash_tenant(['balance_rub' => 5000.0]); $tA2 = dash_tenant(['balance_rub' => 2000.0]); $stepParams = ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]]; dash_assignment($mgrA, $tA1, 'topup_step', $stepParams); dash_assignment($mgrA, $tA2, 'topup_step', $stepParams); // Оборот A1 = 50 000 ₽ (5 000 000 коп), A2 = 10 000 ₽ (1 000 000 коп). dash_leadCharge($tA1->id, 5_000_000, dash_date(10)); dash_leadCharge($tA2->id, 1_000_000, dash_date(11)); // Пополнения того же размера → комиссия topup_step = пополнения × 20%. dash_topup($tA1->id, '50000.00', dash_date(10)); dash_topup($tA2->id, '10000.00', dash_date(11)); // Менеджер B (в отпуске, is_active=false) — 1 клиент-overdue, без пополнений. $mgrB = dash_manager(baseSalary: 20000.0, isActive: false); $tB = dash_tenant(['balance_rub' => -1500.0]); // overdue: balance < 0 dash_assignment($mgrB, $tB, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]); // Выплата менеджеру A в текущем периоде. dash_payout($mgrA->id, $head->id, '12000.00', dash_date(12)); // Выплата ВНЕ периода (прошлый месяц) — в paid_period НЕ входит. dash_payout($mgrA->id, $head->id, '4000.00', CarbonImmutable::now('Europe/Moscow')->subMonthNoOverflow()->format('Y-m-d')); // 1 pending-заявка + 1 issued-счёт. dash_pendingAttachment($mgrA->id); dash_issuedInvoice($tA1->id); $response = $this->actingAs($head, 'sales')->getJson('/api/sales/dashboard/overview?period=this'); $response->assertOk(); $json = $response->json(); // ── kpi ── $kpi = $json['kpi']; expect($kpi['managers_count'])->toBe(2) ->and($kpi['managers_active'])->toBe(1) ->and($kpi['managers_vacation'])->toBe(1) ->and($kpi['clients_count'])->toBe(3) // 3 привязки ->and((float) $kpi['balance_sum_rub'])->toBe(5500.0) // 5000 + 2000 − 1500 ->and((float) $kpi['oborot_rub'])->toBe(60000.0) // 50000 + 10000 ->and((float) $kpi['paid_period_rub'])->toBe(12000.0); // только выплата в периоде // balance_sum_rub — строка number_format(2) expect($kpi['balance_sum_rub'])->toBe('5500.00'); // ── alerts ── $alerts = $json['alerts']; expect($alerts['invoices_awaiting'])->toBeGreaterThanOrEqual(1) ->and($alerts['attachments_pending'])->toBeGreaterThanOrEqual(1) ->and($alerts['clients_balance_problem'])->toBeGreaterThanOrEqual(1); // tB overdue // ── performance ── $perf = collect($json['performance']); $rowA = $perf->firstWhere('manager_id', $mgrA->id); $rowB = $perf->firstWhere('manager_id', $mgrB->id); expect($rowA)->not->toBeNull() ->and($rowA['name'])->toBe($mgrA->name) ->and($rowA['clients_count'])->toBe(2) ->and((float) $rowA['oborot_rub'])->toBe(60000.0) // earned = комиссия topup_step 20% от пополнений (50 000 + 10 000) = 12 000. Оклада нет. ->and((float) $rowA['earned_rub'])->toBe(12000.0) // all-time выплаты A = 12 000 + 4 000 = 16 000. ->and((float) $rowA['paid_all_time_rub'])->toBe(16000.0) ->and($rowA)->toHaveKeys([ 'manager_id', 'name', 'clients_count', 'leads_delivered', 'oborot_rub', 'paid_all_time_rub', 'earned_rub', ]); expect($rowB)->not->toBeNull() ->and($rowB['clients_count'])->toBe(1) // earned B = комиссия 10% от 0 (нет пополнений у tB) = 0. Оклада нет. ->and((float) $rowB['earned_rub'])->toBe(0.0); }); // ── 3. пустой отдел → нули/пустые массивы ──────────────────────────────────── test('head: пустой отдел → нули и пустой performance', function () { $head = dash_head(); $response = $this->actingAs($head, 'sales')->getJson('/api/sales/dashboard/overview'); $response->assertOk(); $json = $response->json(); expect($json['kpi']['managers_count'])->toBe(0) ->and($json['kpi']['managers_active'])->toBe(0) ->and($json['kpi']['managers_vacation'])->toBe(0) ->and($json['kpi']['clients_count'])->toBe(0) ->and($json['kpi']['balance_sum_rub'])->toBe('0.00') ->and((float) $json['kpi']['oborot_rub'])->toBe(0.0) ->and((float) $json['kpi']['paid_period_rub'])->toBe(0.0) ->and($json['performance'])->toBe([]); });