Files
portal/app/tests/Feature/Sales/SalesMetricsServiceTest.php
T

370 lines
16 KiB
PHP
Raw Normal View History

<?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);
});