actingAs($user, 'sales'). * Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php). * DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске. * * ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций. * ⚠️ ПАРТИЦИИ: balance_transactions / lead_charges партиционированы помесячно * динамически → сеем ТОЛЬКО в текущий месяц. */ 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 (уникальный префикс perf_) ─────────────────────────────────────── function perf_head(): SalesUser { return SalesUser::create([ 'name' => 'Начальник '.uniqid(), 'email' => 'perfhead'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'head', 'is_active' => true, ]); } function perf_manager(string $name, bool $isActive = true, float $baseSalary = 0.0): SalesUser { return SalesUser::create([ 'name' => $name, 'email' => 'perfmgr'.uniqid().'@sales.local', 'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => $isActive, 'base_salary_rub' => $baseSalary, ]); } /** Активный клиент: is_trial=false, status=active, баланс≥0, chargeback=0. */ function perf_activeTenant(): Tenant { return Tenant::factory()->create([ 'is_trial' => false, 'status' => 'active', 'balance_rub' => 1000.0, 'chargeback_unrecovered_rub' => 0.0, 'delivered_in_month' => 0, ]); } /** Overdue-клиент: отрицательный баланс → derived-статус 'overdue'. */ function perf_overdueTenant(): Tenant { return Tenant::factory()->create([ 'is_trial' => false, 'status' => 'active', 'balance_rub' => -500.0, 'chargeback_unrecovered_rub' => 0.0, 'delivered_in_month' => 0, ]); } /** @param array $params */ function perf_assignment(SalesUser $user, Tenant $tenant, string $kind, array $params): 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 perf_date(int $day): string { return CarbonImmutable::now('Europe/Moscow')->startOfMonth() ->setDay($day)->format('Y-m-d'); } function perf_leadCharge(int $tenantId, int $kopecks, int $day = 10): void { $dt = perf_date($day).' 10:00:00'; DB::table('lead_charges')->insert([ 'tenant_id' => $tenantId, 'deal_id' => fake()->numberBetween(1, 99999), '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 perf_topup(int $tenantId, string $amountRub, int $day = 10): void { $dt = perf_date($day).' 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 perf_project(int $tenantId): int { return (int) DB::table('projects')->insertGetId([ 'tenant_id' => $tenantId, 'name' => 'Проект '.uniqid(), 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]); } function perf_deal(int $tenantId, int $projectId, int $day = 10): void { $dt = perf_date($day).' 10:00:00'; DB::table('deals')->insert([ 'tenant_id' => $tenantId, 'project_id' => $projectId, 'phone' => '7916'.fake()->numerify('#######'), 'status' => 'new', 'received_at' => $dt, 'is_test' => false, 'created_at' => $dt, 'updated_at' => $dt, ]); } function perf_payout(int $managerId, int $headId, string $amount, int $day = 12): SalesPayout { return SalesPayout::create([ 'sales_user_id' => $managerId, 'amount_rub' => $amount, 'paid_on' => perf_date($day), 'created_by' => $headId, ]); } /** Свежий сервис дохода — для ручной сверки earned_rub. */ function perf_thisMonthRange(): SalesPeriodRange { $start = CarbonImmutable::now('Europe/Moscow')->startOfMonth(); return new SalesPeriodRange( $start->startOfDay(), $start->endOfMonth()->setTime(23, 59, 59), ); } // ── 1. доступ ──────────────────────────────────────────────────────────────── test('менеджер GET /api/sales/managers/performance → 403', function () { $manager = perf_manager('Менеджер '.uniqid()); $this->actingAs($manager, 'sales')->getJson('/api/sales/managers/performance') ->assertStatus(403) ->assertJson(['message' => 'Доступно только начальнику отдела.']); }); // ── 2. happy path (head) ───────────────────────────────────────────────────── test('head: строка на каждого менеджера с корректными метриками и статусом', function () { $head = perf_head(); // Менеджер 1 (active, оклад 30 000), 2 клиента: active + overdue. $m1 = perf_manager('Алексей Первый', isActive: true, baseSalary: 30000.0); $tActive = perf_activeTenant(); $tOverdue = perf_overdueTenant(); // topup_step 20% (оклада нет: у менеджера нет текущего тарифа daily_salary). $stepParams = ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]]; perf_assignment($m1, $tActive, 'topup_step', $stepParams); perf_assignment($m1, $tOverdue, 'topup_step', $stepParams); perf_leadCharge($tActive->id, 5_000_000); // оборот 50 000 ₽ (метрика oborot_rub) perf_topup($tActive->id, '50000.00'); // пополнения 50 000 ₽ → комиссия 20% = 10 000 $pActive = perf_project($tActive->id); $pOverdue = perf_project($tOverdue->id); perf_deal($tActive->id, $pActive); perf_deal($tActive->id, $pActive); // 2 лида на active-клиенте perf_deal($tOverdue->id, $pOverdue); // 1 лид на overdue-клиенте // Одна выплата (paid_all_time). perf_payout($m1->id, $head->id, '7000.00'); // Менеджер 2 (is_active=false → vacation), без клиентов. $m2 = perf_manager('Борис Второй', isActive: false, baseSalary: 25000.0); $response = $this->actingAs($head, 'sales')->getJson('/api/sales/managers/performance'); $response->assertOk(); $data = collect($response->json('data')); $row1 = $data->firstWhere('manager_id', $m1->id); $row2 = $data->firstWhere('manager_id', $m2->id); // earned_rub считаем вручную тем же сервисом (комиссия topup_step 10 000, оклада нет). $earned = round(app(SalesEarningsService::class)->forManager($m1->fresh(), perf_thisMonthRange()), 2); expect($row1)->not->toBeNull() ->and($row1['name'])->toBe('Алексей Первый') ->and($row1['email'])->toBe($m1->email) ->and($row1['clients_count'])->toBe(2) ->and($row1['active_clients'])->toBe(1) ->and($row1['leads_delivered'])->toBe(3) ->and((float) $row1['oborot_rub'])->toBe(50000.0) ->and((float) $row1['paid_all_time_rub'])->toBe(7000.0) ->and((float) $row1['earned_rub'])->toBe(10000.0) ->and((float) $row1['earned_rub'])->toBe($earned) ->and($row1['status'])->toBe('active') ->and($row1)->toHaveKeys([ 'manager_id', 'name', 'email', 'clients_count', 'active_clients', 'leads_delivered', 'oborot_rub', 'paid_all_time_rub', 'earned_rub', 'status', ]); expect($row2)->not->toBeNull() ->and($row2['clients_count'])->toBe(0) ->and($row2['active_clients'])->toBe(0) ->and($row2['status'])->toBe('vacation'); }); // ── 3. search ──────────────────────────────────────────────────────────────── test('search по имени менеджера → только совпадающий', function () { $head = perf_head(); $m1 = perf_manager('Уникальный Зевс', isActive: true); $m2 = perf_manager('Другой Аполлон', isActive: true); $data = collect( $this->actingAs($head, 'sales') ->getJson('/api/sales/managers/performance?search=Зевс') ->json('data') ); expect($data->firstWhere('manager_id', $m1->id))->not->toBeNull() ->and($data->firstWhere('manager_id', $m2->id))->toBeNull(); }); // ── 4. неаутентифицированный ───────────────────────────────────────────────── test('неаутентифицированный GET /api/sales/managers/performance → 401', function () { $this->getJson('/api/sales/managers/performance')->assertUnauthorized(); });