From 2a6b476d6d8b7e509114f2ed11e8a2ea8b8be5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 17 Jun 2026 14:07:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(billing):=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20run?= =?UTF-8?q?way=20=E2=80=94=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20?= =?UTF-8?q?=3D=20=D0=B1=D0=B8=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3=20(F3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дашборд считал «хватит на дни» от legacy balance_leads (≈0 для рублёвых тенантов) и расходился с биллингом. Введён общий RunwayCalculator; оба контроллера считают runway от affordable leads (рубли→лиды по тарифу, BalanceToLeadsConverter). Фронт DashboardView больше не режет число дней до 7 сегментов полосы. TDD: 4 Pest нового сервиса + обновлён DashboardSummary + 1 vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/BillingController.php | 19 +------ .../Controllers/Api/DashboardController.php | 24 +++++--- app/app/Services/Billing/RunwayCalculator.php | 46 +++++++++++++++ app/resources/js/views/DashboardView.vue | 4 +- app/tests/Feature/DashboardSummaryTest.php | 24 +++++--- app/tests/Feature/RunwayCalculatorTest.php | 57 +++++++++++++++++++ app/tests/Frontend/DashboardView.spec.ts | 11 ++++ 7 files changed, 151 insertions(+), 34 deletions(-) create mode 100644 app/app/Services/Billing/RunwayCalculator.php create mode 100644 app/tests/Feature/RunwayCalculatorTest.php diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index 9111d23c..cf155101 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -316,21 +316,8 @@ class BillingController extends Controller */ private function runwayDays(Tenant $tenant, int $affordableLeads): ?int { - if ($affordableLeads <= 0) { - return 0; - } - - $leadsLast30Days = (int) DB::table('lead_charges') - ->where('tenant_id', $tenant->id) - ->where('charged_at', '>=', now()->subDays(30)) - ->count(); - - if ($leadsLast30Days <= 0) { - return null; - } - - $avgPerDay = $leadsLast30Days / 30.0; - - return max(0, (int) floor($affordableLeads / $avgPerDay)); + // F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом), + // чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом. + return app(\App\Services\Billing\RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads); } } diff --git a/app/app/Http/Controllers/Api/DashboardController.php b/app/app/Http/Controllers/Api/DashboardController.php index be929e01..a5854a0b 100644 --- a/app/app/Http/Controllers/Api/DashboardController.php +++ b/app/app/Http/Controllers/Api/DashboardController.php @@ -103,13 +103,21 @@ class DashboardController extends Controller ->map(fn ($c) => (int) $c) ->toArray(); - // --- runway --- - // runway опирается на приток за фиксированное 7-дневное окно, - // независимо от выбранного range (для today/30d $curLeads — не 7-дневный). - $leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count(); - $avgDaily = $leads7d / 7.0; - $balanceLeads = (int) ($tenant->balance_leads ?? 0); - $runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0; + // --- runway (F3, 17.06.2026: единый источник с биллингом) --- + // Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0 + // для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней». + // Теперь — affordable leads от рублёвого баланса по тарифу + // (BalanceToLeadsConverter) + общий RunwayCalculator. + $activeTiers = app(\App\Repositories\PricingTierRepository::class) + ->activeAt(\Carbon\Carbon::now('Europe/Moscow')); + $conversion = app(\App\Services\Billing\BalanceToLeadsConverter::class)->convert( + (string) $tenant->balance_rub, + (int) ($tenant->delivered_in_month ?? 0), + $activeTiers, + ); + $affordableLeads = (int) $conversion['leads']; + $runwayDays = app(\App\Services\Billing\RunwayCalculator::class) + ->daysLeft($tenantId, $affordableLeads) ?? 0; return [ 'range' => $range, @@ -119,7 +127,7 @@ class DashboardController extends Controller 'balance' => [ 'amount_rub' => (string) $tenant->balance_rub, 'runway_days' => $runwayDays, - 'runway_leads' => $balanceLeads, + 'runway_leads' => $affordableLeads, ], 'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax], 'funnel' => (object) $funnel, diff --git a/app/app/Services/Billing/RunwayCalculator.php b/app/app/Services/Billing/RunwayCalculator.php new file mode 100644 index 00000000..4a0225dd --- /dev/null +++ b/app/app/Services/Billing/RunwayCalculator.php @@ -0,0 +1,46 @@ +where('tenant_id', $tenantId) + ->where('charged_at', '>=', now()->subDays(30)) + ->count(); + + if ($leadsLast30Days <= 0) { + return null; + } + + $avgPerDay = $leadsLast30Days / 30.0; + + return max(0, (int) floor($affordableLeads / $avgPerDay)); + } +} diff --git a/app/resources/js/views/DashboardView.vue b/app/resources/js/views/DashboardView.vue index 41465b68..eb9c2d95 100644 --- a/app/resources/js/views/DashboardView.vue +++ b/app/resources/js/views/DashboardView.vue @@ -74,7 +74,9 @@ function applySummary(s: DashboardSummary): void { ]; balance.value = { amount: formatRub(s.balance.amount_rub), - runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX), + // F3: реальное число дней (полоса из RUNWAY_MAX сегментов заполняется + // естественно `i <= runwayDays`; раньше cap до 7 врал в тексте «хватит на N дней»). + runwayDays: s.balance.runway_days, runwayMax: RUNWAY_MAX, runwayLeads: s.balance.runway_leads, }; diff --git a/app/tests/Feature/DashboardSummaryTest.php b/app/tests/Feature/DashboardSummaryTest.php index 7aa82bfb..40ade846 100644 --- a/app/tests/Feature/DashboardSummaryTest.php +++ b/app/tests/Feature/DashboardSummaryTest.php @@ -134,16 +134,22 @@ it('activity возвращает 7 точек и 7 меток', function () { ->assertJsonCount(7, 'activity.labels'); }); -it('runway_days использует фикс. 7д-окно независимо от range', function () { - // balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70. - // Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно). - $tenant = Tenant::factory()->create(['balance_leads' => 70]); +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); - $project = Project::factory()->create(['tenant_id' => $tenant->id]); - for ($i = 0; $i <= 6; $i++) { - makeDashboardDeal($tenant, $project, 'new', now()->subDays($i)); - } + \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_days', 70); + ->assertJsonPath('balance.runway_leads', 28) // от рублей, НЕ 285 (balance_leads) + ->assertJsonPath('balance.runway_days', 28); }); diff --git a/app/tests/Feature/RunwayCalculatorTest.php b/app/tests/Feature/RunwayCalculatorTest.php new file mode 100644 index 00000000..53658fe9 --- /dev/null +++ b/app/tests/Feature/RunwayCalculatorTest.php @@ -0,0 +1,57 @@ +insert([ + 'tenant_id' => $tenantId, + 'deal_id' => 900000 + $i, + 'deal_received_at' => $chargedAt, + 'tier_no' => 1, + 'price_per_lead_kopecks' => 50000, + 'charge_source' => 'rub', + 'charged_at' => $chargedAt, + 'created_at' => now()->toDateTimeString(), + ]); + } +} + +test('daysLeft = 0 если affordableLeads <= 0', function () { + $tenant = Tenant::factory()->create(); + expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 0))->toBe(0); + expect(app(RunwayCalculator::class)->daysLeft($tenant->id, -5))->toBe(0); +}); + +test('daysLeft = null если нет списаний за 30 дней', function () { + $tenant = Tenant::factory()->create(); + expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 100))->toBeNull(); +}); + +test('daysLeft = floor(affordable / средний дневной за 30 дней)', function () { + $tenant = Tenant::factory()->create(); + seedLeadCharges($tenant->id, 30, now()->subDays(5)->toDateTimeString()); // 30/30 = 1 лид/день + expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 90))->toBe(90); +}); + +test('daysLeft игнорирует списания старше 30 дней', function () { + $tenant = Tenant::factory()->create(); + seedLeadCharges($tenant->id, 30, now()->subDays(40)->toDateTimeString()); // вне окна + expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 90))->toBeNull(); +}); diff --git a/app/tests/Frontend/DashboardView.spec.ts b/app/tests/Frontend/DashboardView.spec.ts index d2670e12..b195a617 100644 --- a/app/tests/Frontend/DashboardView.spec.ts +++ b/app/tests/Frontend/DashboardView.spec.ts @@ -85,4 +85,15 @@ describe('DashboardView.vue ↔ /api/dashboard/summary', () => { await flushPromises(); expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2); }); + + // F3 (17.06.2026): runway_days в тексте — реальное число, не срезанное до + // RUNWAY_MAX (7 сегментов полосы). Иначе дашборд расходится с биллингом. + it('показывает реальный runway_days (не срезанный до 7 сегментов полосы)', async () => { + vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce( + makeSummary({ balance: { amount_rub: '14250.00', runway_days: 28, runway_leads: 28 } }), + ); + const wrapper = mountView(); + await flushPromises(); + expect(wrapper.text()).toContain('28 дня'); + }); });