1cb3b56f70
Строка-приветствие показывала захардкоженную рыбу: +3 новых лида с утра, сегодня 11 / вчера 38, средняя стоимость 2 248 руб. Числа ни к чему не были привязаны — остаток прототипа Sprint 4. Бэкенд: DashboardController.summary отдаёт avg_lead_cost_rub — среднее фактически списанных rub-сумм за окно периода: AVG price_per_lead_kopecks WHERE charge_source rub делить на 100; null если в окне нет rub-списаний. Тот же источник, что карточка сделки F2. Фронт: DashboardPageHead принимает пропы сегодня/вчера/средняя; сегодня и вчера берутся из activity.points последняя точка сегодня; средняя из avg_lead_cost_rub, прочерк при null. Размытое +3 с утра убрано. TDD: 2 Pest DashboardSummaryTest 10/10 + 4 vitest DashboardPageHead; полная фронт-сюита 959 passed / 3 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
9.1 KiB
PHP
213 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Deal;
|
|
use App\Models\Project;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Carbon\Carbon;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
/**
|
|
* Вспомогательная функция: создать сделку с заданными параметрами.
|
|
*
|
|
* Фабрика Deal::factory() по умолчанию: received_at = now() (текущий месяц,
|
|
* партиция deals_2026_05 существует). is_test = false, deleted_at = null.
|
|
* Для тестовых дат subDays(1..6) — всё в мае 2026, партиция есть.
|
|
*/
|
|
function makeDashboardDeal(
|
|
Tenant $tenant,
|
|
Project $project,
|
|
string $status,
|
|
Carbon|CarbonImmutable $receivedAt,
|
|
?Carbon $deletedAt = null,
|
|
bool $isTest = false,
|
|
): Deal {
|
|
return Deal::factory()->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(\Database\Seeders\PricingTierSeeder::class);
|
|
$tenant = Tenant::factory()->create(['balance_rub' => '14250.00', 'balance_leads' => 285]);
|
|
actingForTenant($tenant);
|
|
\App\Models\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);
|
|
\App\Models\LeadCharge::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'charge_source' => 'rub',
|
|
'price_per_lead_kopecks' => 50000,
|
|
'charged_at' => now()->subDays(1),
|
|
]);
|
|
\App\Models\LeadCharge::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'charge_source' => 'rub',
|
|
'price_per_lead_kopecks' => 70000,
|
|
'charged_at' => now()->subDays(2),
|
|
]);
|
|
\App\Models\LeadCharge::factory()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'charge_source' => 'rub',
|
|
'price_per_lead_kopecks' => 60000,
|
|
'charged_at' => now()->subDays(3),
|
|
]);
|
|
// prepaid (цена 0) — не участвует в средней
|
|
\App\Models\LeadCharge::factory()->prepaid()->create([
|
|
'tenant_id' => $tenant->id,
|
|
'charged_at' => now()->subDays(1),
|
|
]);
|
|
// rub-списание вне окна 7d (8 дней назад) — не участвует
|
|
\App\Models\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-списаний нет → среднего нет
|
|
\App\Models\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);
|
|
});
|