create([ 'tenant_id' => $tenant->id, 'project_id' => $project->id, 'status' => $status, 'received_at' => $receivedAt, 'deleted_at' => $deletedAt, 'is_test' => $isTest, ]); } /** Авторизоваться как пользователь данного тенанта (auth:sanctum + tenant). */ function actingForTenant(Tenant $tenant): void { test()->actingAs(User::factory()->for($tenant)->create()); } it('401 без авторизации', function () { $this->getJson('/api/dashboard/summary')->assertStatus(401); }); it('возвращает структуру summary с range по умолчанию 7d', function () { $tenant = Tenant::factory()->create([ 'limits' => ['max_projects' => 10], 'balance_rub' => '14250.00', 'balance_leads' => 285, ]); actingForTenant($tenant); $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('range', '7d') ->assertJsonPath('balance.amount_rub', '14250.00') ->assertJsonStructure([ 'range', 'leads_received' => ['value', 'delta_pct', 'delta_dir'], 'conversion' => ['value', 'delta_pp', 'delta_dir'], 'active_projects' => ['active', 'limit'], 'balance' => ['amount_rub', 'runway_days', 'runway_leads'], 'activity' => ['points', 'labels', 'max'], 'funnel', 'avg_lead_cost_rub', ]); }); it('leads_received считает только сделки окна, без deleted и is_test', function () { $tenant = Tenant::factory()->create(); actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); // 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад) makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(2)); makeDashboardDeal($tenant, $project, 'won', now()->subDays(3)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now()); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true); makeDashboardDeal($tenant, $project, 'new', now()->subDays(8)); $this->getJson('/api/dashboard/summary?range=7d') ->assertOk() ->assertJsonPath('leads_received.value', 3); }); it('conversion = доля статуса won в окне', function () { $tenant = Tenant::factory()->create(); actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); makeDashboardDeal($tenant, $project, 'won', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); // 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби) $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('conversion.value', 25); }); it('active_projects считает is_active=true + limit из limits', function () { $tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]); actingForTenant($tenant); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]); Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => false]); $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('active_projects.active', 2) ->assertJsonPath('active_projects.limit', 10); }); it('funnel группирует живые сделки по статусу', function () { $tenant = Tenant::factory()->create(); actingForTenant($tenant); $project = Project::factory()->create(['tenant_id' => $tenant->id]); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'new', now()->subDays(1)); makeDashboardDeal($tenant, $project, 'won', now()->subDays(1)); $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonPath('funnel.new', 2) ->assertJsonPath('funnel.won', 1); }); it('activity возвращает 7 точек и 7 меток', function () { $tenant = Tenant::factory()->create(); actingForTenant($tenant); $this->getJson('/api/dashboard/summary') ->assertOk() ->assertJsonCount(7, 'activity.points') ->assertJsonCount(7, 'activity.labels'); }); it('runway считается от рублёвого баланса (единый источник с биллингом, F3)', function () { // F3: runway дашборда теперь = affordable leads (рубли→лиды по тарифу) + общий // RunwayCalculator, как в биллинге. balance_leads (285) больше НЕ используется. // 14250₽, tier1 500₽, delivered=0 → affordable = floor(1425000/50000) = 28 лидов. // 30 списаний за 30 дней → avg 1 лид/день → runway = floor(28/1) = 28. // Те же числа, что в BillingOverviewControllerTest — доказывает единый источник. $this->seed(PricingTierSeeder::class); $tenant = Tenant::factory()->create(['balance_rub' => '14250.00', 'balance_leads' => 285]); actingForTenant($tenant); LeadCharge::factory()->count(30)->create([ 'tenant_id' => $tenant->id, 'charged_at' => now()->subDays(rand(1, 30)), ]); $this->getJson('/api/dashboard/summary?range=today') ->assertOk() ->assertJsonPath('balance.runway_leads', 28) // от рублей, НЕ 285 (balance_leads) ->assertJsonPath('balance.runway_days', 28); }); it('avg_lead_cost_rub = среднее rub-списаний окна, без prepaid и вне окна', function () { // Клиентская «средняя стоимость» = среднее фактически списанных rub-сумм // (lead_charges.price_per_lead_kopecks, charge_source='rub') за окно периода. // 500 + 700 + 600 = 1800 / 3 = 600 ₽. prepaid (0 ₽) и списание вне окна — не считаются. $tenant = Tenant::factory()->create(); actingForTenant($tenant); LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 50000, 'charged_at' => now()->subDays(1), ]); LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 70000, 'charged_at' => now()->subDays(2), ]); LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 60000, 'charged_at' => now()->subDays(3), ]); // prepaid (цена 0) — не участвует в средней LeadCharge::factory()->prepaid()->create([ 'tenant_id' => $tenant->id, 'charged_at' => now()->subDays(1), ]); // rub-списание вне окна 7d (8 дней назад) — не участвует LeadCharge::factory()->create([ 'tenant_id' => $tenant->id, 'charge_source' => 'rub', 'price_per_lead_kopecks' => 100000, 'charged_at' => now()->subDays(8), ]); $this->getJson('/api/dashboard/summary?range=7d') ->assertOk() ->assertJsonPath('avg_lead_cost_rub', 600); }); it('avg_lead_cost_rub = null если нет rub-списаний в окне', function () { $tenant = Tenant::factory()->create(); actingForTenant($tenant); // только prepaid — rub-списаний нет → среднего нет LeadCharge::factory()->prepaid()->create([ 'tenant_id' => $tenant->id, 'charged_at' => now()->subDays(1), ]); $this->getJson('/api/dashboard/summary?range=7d') ->assertOk() ->assertJsonPath('avg_lead_cost_rub', null); });