fix(billing): единый источник runway — дашборд = биллинг (F3)
Дашборд считал «хватит на дни» от 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Единый расчёт «на сколько дней хватит affordable_leads» (F3, 17.06.2026).
|
||||
*
|
||||
* Единственный источник истины для прогноза runway — используется и биллингом
|
||||
* (BillingController::wallet), и дашбордом (DashboardController::summary), чтобы
|
||||
* исключить расхождение «0 дней (дашборд) ↔ N дней (биллинг)»: раньше дашборд
|
||||
* считал от legacy `balance_leads`, а биллинг — от рублёвого affordable.
|
||||
*
|
||||
* Billing v2 Spec A: affordable_leads — выход BalanceToLeadsConverter (точная
|
||||
* конверсия ₽ по ступеням), делённый на среднюю скорость списания за 30 дней
|
||||
* (count(lead_charges)/30).
|
||||
*
|
||||
* - affordable_leads ≤ 0 → 0 (тенант не может купить ни одного лида).
|
||||
* - leadsLast30Days = 0 → null (нет истории, не от чего считать).
|
||||
* - иначе → floor(affordable_leads / (leadsLast30Days / 30)).
|
||||
*/
|
||||
class RunwayCalculator
|
||||
{
|
||||
public function daysLeft(int $tenantId, int $affordableLeads): ?int
|
||||
{
|
||||
if ($affordableLeads <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$leadsLast30Days = (int) DB::table('lead_charges')
|
||||
->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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* F3 (17.06.2026): единый расчёт runway для биллинга и дашборда.
|
||||
* daysLeft(tenantId, affordableLeads):
|
||||
* affordable<=0 → 0; нет списаний за 30 дн → null;
|
||||
* иначе floor(affordable / (lead_charges за 30 дн / 30)).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/** Вставляет $count списаний с датой $chargedAt (deferred FK на deals — тест откатывается). */
|
||||
function seedLeadCharges(int $tenantId, int $count, string $chargedAt): void
|
||||
{
|
||||
DB::statement('SET app.current_tenant_id = '.$tenantId);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
DB::table('lead_charges')->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();
|
||||
});
|
||||
@@ -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 дня');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user