55c14fc7c2
Task 1.2: SalesMetricsService — leadsDelivered/oborotRub/topupsRub/cumulativeTopupsRub/runwayDays. Деньги: оборот из целых копеек (/100 в конце), полуоткрытые интервалы [start, след.день). runwayDays реиспользует PricingTierRepository→BalanceToLeadsConverter→RunwayCalculator (совпадает с кабинетом клиента, F3). leadsDelivered 1:1 с DashboardController (deleted_at NULL, is_test=false). Тест 15/15 (с граничными), stan 0. Один эскейп на сессию. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
370 lines
16 KiB
PHP
370 lines
16 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Models\Deal;
|
||
use App\Models\PricingTier;
|
||
use App\Models\Project;
|
||
use App\Models\Tenant;
|
||
use App\Repositories\PricingTierRepository;
|
||
use App\Services\Billing\BalanceToLeadsConverter;
|
||
use App\Services\Billing\RunwayCalculator;
|
||
use App\Services\Sales\SalesMetricsService;
|
||
use App\Services\Sales\SalesPeriodRange;
|
||
use Carbon\Carbon;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* TDD: SalesMetricsService — метрики периода для портала продаж.
|
||
*
|
||
* Покрывает Task 1.2: leadsDelivered / oborotRub / topupsRub /
|
||
* cumulativeTopupsRub / runwayDays.
|
||
*
|
||
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
|
||
* Использует DEFAULT connection (pgsql → liderra_testing).
|
||
*
|
||
* CRITICAL: деньги — INTEGER kopecks → SUM → / 100. Float-суммирование запрещено.
|
||
* Интервал: half-open [range.start, range.end.startOfDay().addDay()).
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Строим SalesPeriodRange по строкам дат (МСК).
|
||
*/
|
||
function makePeriodRange(string $startDate, string $endDate): SalesPeriodRange
|
||
{
|
||
$tz = 'Europe/Moscow';
|
||
|
||
return new SalesPeriodRange(
|
||
CarbonImmutable::parse($startDate.' 00:00:00', $tz),
|
||
CarbonImmutable::parse($endDate.' 23:59:59', $tz),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Создаёт тенанта с балансом и активным проектом.
|
||
*
|
||
* @return array{tenant: Tenant, project: Project}
|
||
*/
|
||
function makeTenantWithProject(float $balanceRub = 10000.0, int $dailyLimit = 10): array
|
||
{
|
||
$tenant = Tenant::factory()->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);
|
||
});
|