Files
portal/app/tests/Feature/Sales/SalesMetricsServiceTest.php
T
Дмитрий bfbdc93712 test(sales): устойчивость SalesMetricsServiceTest к сбросу тест-БД
balance_transactions партиции создаются динамически (partitions:create-months = текущий+будущие), НЕ в schema.sql → при migrate:fresh общей тест-БД прошлые месяцы не восстанавливаются, июньские топап-тесты падали 'нет секции'. Привязал 4 balance_transactions-теста к ТЕКУЩЕМУ месяцу (btDate/btThisMonthRange/btLastDay/btNextMonthStart) — у него партиция есть всегда. deals/lead_charges тесты не трогал (их партиции в схеме, переживают fresh). 15/15. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:02:03 +03:00

407 lines
17 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,
]);
}
/**
* Дата/время в ТЕКУЩЕМ месяце МСК (день $day).
*
* Зачем: партиции balance_transactions создаются динамически
* (partitions:create-months = текущий + будущие месяцы) и НЕ прописаны в schema.sql,
* поэтому при migrate:fresh общей тест-БД прошлые месяцы не восстанавливаются →
* INSERT в прошлый месяц падает "нет секции". Текущий месяц всегда покрыт.
* (deals/lead_charges — партиции в схеме, переживают fresh, там даты можно хардкодить.)
*/
function btDate(int $day, string $time = '10:00:00'): string
{
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
->setDay($day)->format('Y-m-d').' '.$time;
}
/** SalesPeriodRange, покрывающий весь текущий месяц МСК. */
function btThisMonthRange(): SalesPeriodRange
{
$start = CarbonImmutable::now('Europe/Moscow')->startOfMonth();
return new SalesPeriodRange(
$start->startOfDay(),
$start->endOfMonth()->setTime(23, 59, 59),
);
}
/** Последний день текущего месяца в 23:59:59 (строка). */
function btLastDay(): string
{
return CarbonImmutable::now('Europe/Moscow')->endOfMonth()->format('Y-m-d').' 23:59:59';
}
/** Первый день СЛЕДУЮЩЕГО месяца в 00:00:00 (строка) — граница «вне периода». */
function btNextMonthStart(): string
{
return CarbonImmutable::now('Europe/Moscow')->addMonth()->startOfMonth()->format('Y-m-d').' 00:00:00';
}
// ── 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 = btThisMonthRange();
// 3 topup в диапазоне (текущий месяц)
insertBalanceTx($tenant->id, 'topup', '1000.00', btDate(5));
insertBalanceTx($tenant->id, 'topup', '2000.00', btDate(20));
insertBalanceTx($tenant->id, 'topup', '500.00', btDate(28));
// topup вне диапазона (следующий месяц) — НЕ считается
insertBalanceTx($tenant->id, 'topup', '9999.00', btNextMonthStart());
// другой тип в диапазоне — НЕ считается
insertBalanceTx($tenant->id, 'lead_charge', '100.00', btDate(15));
insertBalanceTx($tenant->id, 'manual_adjustment', '50.00', btDate(16));
expect($service->topupsRub($tenant->id, $range))->toBe(3500.0);
});
test('topupsRub: boundary — topup в 23:59:59 последнего дня включается, в следующую полночь — нет', function () {
['tenant' => $tenant] = makeTenantWithProject();
$service = new SalesMetricsService;
$range = btThisMonthRange();
insertBalanceTx($tenant->id, 'topup', '100.00', btLastDay()); // включается
insertBalanceTx($tenant->id, 'topup', '999.00', btNextMonthStart()); // НЕ включается
expect($service->topupsRub($tenant->id, $range))->toBe(100.0);
});
// ── 4. cumulativeTopupsRub ────────────────────────────────────────────────
test('cumulativeTopupsRub: суммирует ВСЕ topup этого тенанта за всё время', function () {
['tenant' => $tenant] = makeTenantWithProject();
$service = new SalesMetricsService;
// Все в текущем месяце (cumulativeTopupsRub суммирует за всё время
// независимо от партиции — кросс-месячность здесь не важна для логики сервиса).
insertBalanceTx($tenant->id, 'topup', '1000.00', btDate(5));
insertBalanceTx($tenant->id, 'topup', '2000.00', btDate(15));
insertBalanceTx($tenant->id, 'topup', '500.00', btDate(25));
// Другой тип — НЕ считается
insertBalanceTx($tenant->id, 'lead_charge', '9999.00', btDate(20));
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', btDate(1));
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);
});