Files
portal/app/tests/Feature/Sales/SalesMetricsServiceTest.php
T
Дмитрий 3c8de85701 fix отдел продаж: сутки в отчётах снова считаются с полуночи, а не с трёх ночи
Живая ошибка на боевом. Пополнение клиента в 01:00 попадало во вчерашний день,
а первые три часа первого числа месяца — в прошлый месяц. Руководитель видел
деньги не за тот день, и ни ошибки, ни следа в журнале при этом не было.

Причина. Границу суток портал считает по Москве, а в базу отправляет БЕЗ пояса —
просто «02.08 00:00». Сеанс базы живёт по Гринвичу и читает эту надпись как
гринвичскую, то есть как 03:00 по Москве. Замер прямым запросом: граница
«полночь по Москве» превращается в базе в «три часа ночи по Москве».

Лечение. Границы периода уходят в запрос МГНОВЕНИЯМИ, а не надписью на часах.
Правило названо в одном месте — в описании периода (SalesPeriodRange) — двумя
методами startInstant / endExclusiveInstant, и там же сказано, где их брать НЕЛЬЗЯ:
для сравнения с календарной датой (paid_on) и для счёта дней нужен именно
московский календарь, там перевод в UTC всё сломает.

Почему это не ловилось раньше. Ошибка видна ТОЛЬКО ночью: тот же код в 23:55
давал зелёный прогон, в 00:20 — красный. Попалась случайно, прогон пересёк
полночь. Оба теста теперь морозят часы на 00:30 МСК — то есть проверяют дыру
в любое время суток, а не когда повезёт. Часы возвращаются в afterEach.

Отдельная находка того же захода: ЧЕТЫРЕ проверки границ ЗАКРЕПЛЯЛИ ошибку.
Они клали данные голой строкой (то есть по Гринвичу), а период строили по Москве —
две ошибки гасили друг друга. Починил границу — они честно покраснели. Данные
в них теперь тоже московские, и пояс пишется прямо в строке: объект даты по
дороге в базу пояс теряет, а строка доходит как есть.

Приёмка вырезанием в обе стороны: убрал перевод в мгновения — покраснели три
проверки, включая обе денежные; вернул — папка продаж 481 из 481 зелёная.
Полный прогон: 4111 тестов, 4107 зелёных, 4 пропущено, 0 падений. Статанализ 0.
После полного прогона база чиста — сторож чистоты подтверждает.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:49:03 +03:00

429 lines
18 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];
}
/**
* Отметка времени фикстуры — В МОСКОВСКОМ ПОЯСЕ, со смещением в самой строке.
*
* 🪤 Раньше фикстуры клались голыми строками вида «2026-06-30 23:59:59». Сеанс базы
* живёт в UTC, поэтому такая строка означала 23:59:59 UTC, то есть 02:59 МСК СЛЕДУЮЩЕГО
* дня — а период при этом строился в МСК. Две ошибки гасили друг друга, пока граница
* периода тоже уезжала в UTC; починили границу — и проверки границ поехали.
*
* Смещение пишем прямо в строку: Laravel подставляет объект даты в запрос БЕЗ смещения,
* а строку — как есть. Смещение берём у пояса, а не «+03» руками.
*/
function mskDate(string $naive): string
{
return CarbonImmutable::parse($naive, 'Europe/Moscow')->toIso8601String();
}
/**
* Вставляет deal напрямую через DB::table (обходим RLS — в тестах superuser).
*/
function insertDeal(int $tenantId, int $projectId, string $receivedAt, bool $isTest = false, ?int $duplicateOfId = null, bool $softDeleted = false): void
{
$receivedAt = mskDate($receivedAt);
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
{
$chargedAt = mskDate($chargedAt);
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
{
$createdAt = mskDate($createdAt);
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);
});