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