create([ 'balance_rub' => $balanceRub, 'delivered_in_month' => 0, ]); $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => $dailyLimit, ]); return ['tenant' => $tenant, 'project' => $project]; } /** * Вставляет deal напрямую через DB::table (обходим RLS — в тестах superuser). */ function insertDeal(int $tenantId, int $projectId, string $receivedAt, bool $isTest = false, ?int $duplicateOfId = null, bool $softDeleted = false): void { DB::table('deals')->insert([ 'tenant_id' => $tenantId, 'project_id' => $projectId, 'source_crm_id' => fake()->unique()->numberBetween(100_000_000, 999_999_999), 'phone' => '7'.fake()->numerify('##########'), 'status' => 'new', 'contact_name' => 'Test Lead', 'escalated_count' => 0, 'is_test' => $isTest, 'duplicate_of_id' => $duplicateOfId, 'received_at' => $receivedAt, 'created_at' => $receivedAt, 'updated_at' => $receivedAt, 'deleted_at' => $softDeleted ? $receivedAt : null, ]); } /** * Вставляет lead_charge напрямую. */ function insertLeadCharge(int $tenantId, int $kopecks, string $chargedAt): void { DB::table('lead_charges')->insert([ 'tenant_id' => $tenantId, 'deal_id' => fake()->numberBetween(1, 99999), 'deal_received_at' => $chargedAt, 'tier_no' => 1, 'price_per_lead_kopecks' => $kopecks, 'charge_source' => 'rub', 'charged_at' => $chargedAt, 'created_at' => $chargedAt, ]); } /** * Вставляет balance_transaction напрямую. */ function insertBalanceTx(int $tenantId, string $type, string $amountRub, string $createdAt): void { DB::table('balance_transactions')->insert([ 'tenant_id' => $tenantId, 'type' => $type, 'amount_rub' => $amountRub, 'amount_leads' => 0, 'balance_rub_after' => $amountRub, 'description' => 'test', 'created_at' => $createdAt, ]); } // ── 1. leadsDelivered ────────────────────────────────────────────────────── test('leadsDelivered: считает только not-deleted, not-test сделки в диапазоне', function () { ['tenant' => $tenant, 'project' => $project] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); // 3 нормальных в диапазоне insertDeal($tenant->id, $project->id, '2026-06-15 10:00:00'); insertDeal($tenant->id, $project->id, '2026-06-20 10:00:00'); insertDeal($tenant->id, $project->id, '2026-06-01 00:00:00'); // ровно старт // soft-deleted — НЕ считается insertDeal($tenant->id, $project->id, '2026-06-10 12:00:00', softDeleted: true); // is_test — НЕ считается insertDeal($tenant->id, $project->id, '2026-06-12 09:00:00', isTest: true); // вне диапазона (следующий день после endDate) insertDeal($tenant->id, $project->id, '2026-07-01 00:00:00'); // вне диапазона (до startDate) insertDeal($tenant->id, $project->id, '2026-05-31 23:59:59'); expect($service->leadsDelivered($tenant->id, $range))->toBe(3); }); test('leadsDelivered: дубли (duplicate_of_id != null) ВКЛЮЧАЮТСЯ — совпадает с DashboardController', function () { ['tenant' => $tenant, 'project' => $project] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); // Дубль — duplicate_of_id заполнен, но сделка не удалена → СЧИТАЕТСЯ insertDeal($tenant->id, $project->id, '2026-06-15 10:00:00', duplicateOfId: 12345); insertDeal($tenant->id, $project->id, '2026-06-16 10:00:00'); expect($service->leadsDelivered($tenant->id, $range))->toBe(2); }); test('leadsDelivered: boundary — сделка ровно в 23:59:59 последнего дня включается', function () { ['tenant' => $tenant, 'project' => $project] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); // 23:59:59 последнего дня — ВКЛЮЧАЕТСЯ insertDeal($tenant->id, $project->id, '2026-06-30 23:59:59'); // следующая полночь — НЕ включается insertDeal($tenant->id, $project->id, '2026-07-01 00:00:00'); expect($service->leadsDelivered($tenant->id, $range))->toBe(1); }); test('leadsDelivered: другой тенант не считается', function () { ['tenant' => $tenant, 'project' => $project] = makeTenantWithProject(); ['tenant' => $other, 'project' => $otherProject] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); insertDeal($other->id, $otherProject->id, '2026-06-15 10:00:00'); expect($service->leadsDelivered($tenant->id, $range))->toBe(0); }); // ── 2. oborotRub ────────────────────────────────────────────────────────── test('oborotRub: SUM(price_per_lead_kopecks) / 100 только за период', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); // 3 × 15000 kopecks в диапазоне = 450 руб. insertLeadCharge($tenant->id, 15000, '2026-06-10 10:00:00'); insertLeadCharge($tenant->id, 15000, '2026-06-15 10:00:00'); insertLeadCharge($tenant->id, 15000, '2026-06-29 10:00:00'); // 1 вне диапазона insertLeadCharge($tenant->id, 15000, '2026-07-01 00:00:00'); expect($service->oborotRub($tenant->id, $range))->toBe(450.0); }); test('oborotRub: пустой период → 0.0', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); expect($service->oborotRub($tenant->id, $range))->toBe(0.0); }); test('oborotRub: boundary — заряд в 23:59:59 последнего дня включается, в следующую полночь — нет', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); insertLeadCharge($tenant->id, 20000, '2026-06-30 23:59:59'); // 200 руб — включается insertLeadCharge($tenant->id, 50000, '2026-07-01 00:00:00'); // 500 руб — НЕ включается expect($service->oborotRub($tenant->id, $range))->toBe(200.0); }); test('oborotRub: не суммирует float-ошибки — целочисленный kopeck SUM', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); // 7 × 15700 kopecks = 109900 kopecks = 1099.00 руб. (без float-погрешности) for ($i = 0; $i < 7; $i++) { insertLeadCharge($tenant->id, 15700, '2026-06-15 10:00:00'); } // 7 * 15700 = 109900 kopecks = 1099.00 rub expect($service->oborotRub($tenant->id, $range))->toBe(1099.0); }); // ── 3. topupsRub ────────────────────────────────────────────────────────── test('topupsRub: суммирует только type=topup за период', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-07-01', '2026-07-31'); // 3 topup в диапазоне insertBalanceTx($tenant->id, 'topup', '1000.00', '2026-07-05 10:00:00'); insertBalanceTx($tenant->id, 'topup', '2000.00', '2026-07-20 10:00:00'); insertBalanceTx($tenant->id, 'topup', '500.00', '2026-07-30 10:00:00'); // topup вне диапазона (в следующем месяце — попадает в другую партицию) insertBalanceTx($tenant->id, 'topup', '9999.00', '2026-08-01 00:00:00'); // topup до начала диапазона — в предыдущем месяце (в существующей партиции 2026-06) insertBalanceTx($tenant->id, 'topup', '8888.00', '2026-06-30 10:00:00'); // другой тип в диапазоне — НЕ считается insertBalanceTx($tenant->id, 'lead_charge', '100.00', '2026-07-15 10:00:00'); insertBalanceTx($tenant->id, 'manual_adjustment', '50.00', '2026-07-16 10:00:00'); expect($service->topupsRub($tenant->id, $range))->toBe(3500.0); }); test('topupsRub: boundary — topup в 23:59:59 последнего дня включается, в следующую полночь — нет', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; $range = makePeriodRange('2026-06-01', '2026-06-30'); insertBalanceTx($tenant->id, 'topup', '100.00', '2026-06-30 23:59:59'); // включается insertBalanceTx($tenant->id, 'topup', '999.00', '2026-07-01 00:00:00'); // НЕ включается expect($service->topupsRub($tenant->id, $range))->toBe(100.0); }); // ── 4. cumulativeTopupsRub ──────────────────────────────────────────────── test('cumulativeTopupsRub: суммирует ВСЕ topup этого тенанта за всё время', function () { ['tenant' => $tenant] = makeTenantWithProject(); $service = new SalesMetricsService; // Три транзакции в разные партиции (все существуют в liderra_testing: 2026-06, 2026-07, 2026-08) insertBalanceTx($tenant->id, 'topup', '1000.00', '2026-06-10 10:00:00'); insertBalanceTx($tenant->id, 'topup', '2000.00', '2026-07-15 10:00:00'); insertBalanceTx($tenant->id, 'topup', '500.00', '2026-08-01 09:00:00'); // Другой тип — НЕ считается insertBalanceTx($tenant->id, 'lead_charge', '9999.00', '2026-06-20 10:00:00'); expect($service->cumulativeTopupsRub($tenant->id))->toBe(3500.0); }); test('cumulativeTopupsRub: другой тенант не считается', function () { ['tenant' => $tenant] = makeTenantWithProject(); ['tenant' => $other] = makeTenantWithProject(); $service = new SalesMetricsService; insertBalanceTx($other->id, 'topup', '5000.00', '2026-06-01 10:00:00'); expect($service->cumulativeTopupsRub($tenant->id))->toBe(0.0); }); // ── 5. runwayDays ───────────────────────────────────────────────────────── test('runwayDays: возвращает то же что RunwayCalculator для одинаковых входных данных', function () { // Создаём PricingTier чтобы BalanceToLeadsConverter имел данные. PricingTier::factory()->create([ 'tier_no' => 1, 'leads_in_tier' => null, // безлимитный 'price_per_lead_kopecks' => 2000, // 20 руб/лид 'is_active' => true, 'effective_from' => '2020-01-01', ]); // Тенант с балансом 10000 руб., 0 delivered_in_month, проект с лимитом 10 ['tenant' => $tenant, 'project' => $project] = makeTenantWithProject(balanceRub: 10000.0, dailyLimit: 10); $service = new SalesMetricsService; $result = $service->runwayDays($tenant->id); // Вычисляем ожидаемое: 10000 руб → сколько лидов по 20 руб = 500 лидов // daily_limit = 10 → runway = 500 / 10 = 50 дней $activeTiers = app(PricingTierRepository::class) ->activeAt(Carbon::now('Europe/Moscow')); $conversion = app(BalanceToLeadsConverter::class)->convert( (string) $tenant->balance_rub, (int) ($tenant->delivered_in_month ?? 0), $activeTiers, ); $affordableLeads = (int) $conversion['leads']; $expected = app(RunwayCalculator::class)->daysLeft($tenant->id, $affordableLeads); expect($result)->toBe($expected)->toBe(50); }); test('runwayDays: нет активных проектов → null (нечего считать)', function () { $tenant = Tenant::factory()->create([ 'balance_rub' => 5000.0, 'delivered_in_month' => 0, ]); // Проект есть но не активен Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => false, 'daily_limit_target' => 10, ]); PricingTier::factory()->create([ 'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 2000, 'is_active' => true, 'effective_from' => '2020-01-01', ]); $service = new SalesMetricsService; expect($service->runwayDays($tenant->id))->toBeNull(); }); test('runwayDays: нулевой баланс → 0 дней', function () { PricingTier::factory()->create([ 'tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 2000, 'is_active' => true, 'effective_from' => '2020-01-01', ]); ['tenant' => $tenant] = makeTenantWithProject(balanceRub: 0.0, dailyLimit: 10); $service = new SalesMetricsService; expect($service->runwayDays($tenant->id))->toBe(0); });