Files
portal/app/tests/Feature/Sales/SalesMetricsServiceTest.php
T
Дмитрий 55c14fc7c2 feat(sales): сервис метрик клиента за период
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>
2026-06-30 13:46:31 +03:00

370 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
});