Files
portal/app/tests/Feature/Sales/SalesOverviewTest.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

563 lines
24 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\SalesClientAssignment;
use App\Models\SalesUser;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Testing\TestResponse;
/**
* TDD: GET /api/sales/overview — «Сводка менеджера».
*
* Покрывает Task 1.5 портала продаж:
* - Менеджер видит агрегаты только по СВОИМ клиентам.
* - Начальник видит агрегаты по ВСЕМ клиентам.
* - Менеджер без клиентов → clients_count=0, пустые массивы.
* - totals: clients_count, active/trial/overdue, balance_sum_rub, leads_delivered, oborot_rub, earned_rub=null.
* - attention: клиенты с overdue/suspended/runway_days=0, отсортированы worst-first, ≤10.
* - top_clients: топ-4 по leads_delivered за период, desc.
*
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
* SharesAdminPdo применяется глобально в Pest.php.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.5)
*/
uses(DatabaseTransactions::class);
// Возврат часов. Два теста ниже морозят время на 00:30 МСК; без возврата
// сдвинутые часы утекают в соседние тесты того же процесса.
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
// ── helpers ───────────────────────────────────────────────────────────────────
function ov_makeSalesUser(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => ucfirst($role).' '.uniqid(),
'email' => 'ov-test-'.$role.uniqid().'@test.local',
'password' => Hash::make('secret'),
'role' => $role,
'is_active' => true,
]);
}
/**
* @param array<string,mixed> $params
*/
function ov_getOverview(SalesUser $user, array $params = []): TestResponse
{
$token = $user->createToken('sales')->plainTextToken;
$url = '/api/sales/overview';
if ($params !== []) {
$url .= '?'.http_build_query($params);
}
return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url);
}
function ov_makeTenant(
string $orgName,
bool $isTrial = false,
string $status = 'active',
float $balance = 500.0,
float $chargeback = 0.0,
): Tenant {
return Tenant::factory()->create([
'organization_name' => $orgName,
'status' => $status,
'is_trial' => $isTrial,
'balance_rub' => (string) $balance,
'chargeback_unrecovered_rub' => (string) $chargeback,
]);
}
function ov_assign(SalesUser $manager, Tenant $tenant): SalesClientAssignment
{
return SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => null,
'tariff_kind' => null,
'tariff_params' => [],
'assigned_at' => now(),
]);
}
/**
* Вставляет deal (лид) в текущем месяце, чтобы не упасть на отсутствии партиции.
*/
function ov_insertDeal(int $tenantId, string $receivedAt): void
{
$projectId = (int) DB::table('projects')
->where('tenant_id', $tenantId)
->value('id');
if ($projectId === 0) {
$projectId = (int) DB::table('projects')->insertGetId([
'tenant_id' => $tenantId,
'name' => 'Overview Test Project',
'tag' => 'ov-test-'.uniqid(),
'is_active' => true,
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('deals')->insert([
'tenant_id' => $tenantId,
'project_id' => $projectId,
'source_crm_id' => rand(100_000_000, 999_999_999),
'phone' => '7'.str_pad((string) rand(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'status' => 'new',
'is_test' => false,
'received_at' => $receivedAt,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* Вставляет lead_charge в текущем месяце (чтобы партиция существовала).
*
* @param int $kopecks цена лида в копейках
*/
function ov_insertCharge(int $tenantId, int $projectId, string $chargedAt, int $kopecks): void
{
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'project_id' => $projectId,
'source_crm_deal_id' => rand(100_000_000, 999_999_999),
'price_per_lead_kopecks' => $kopecks,
'charged_at' => $chargedAt,
'created_at' => now(),
'updated_at' => now(),
]);
}
// ── тесты ────────────────────────────────────────────────────────────────────
test('менеджер: totals по 3 своим клиентам — active/trial/overdue, суммы', function () {
$manager = ov_makeSalesUser('manager');
// active с балансом 1000
$tActive = ov_makeTenant('Актив', false, 'active', 1000.0);
// trial
$tTrial = ov_makeTenant('Триал', true, 'active', 200.0);
// overdue — balance < 0
$tOverdue = ov_makeTenant('Должник', false, 'active', -50.0);
ov_assign($manager, $tActive);
ov_assign($manager, $tTrial);
ov_assign($manager, $tOverdue);
// Добавляем 2 лида к активному тенанту в текущем месяце
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
ov_insertDeal($tActive->id, $nowStr);
ov_insertDeal($tActive->id, $nowStr);
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
$totals = $response->json('totals');
expect($totals['clients_count'])->toBe(3);
expect($totals['active'])->toBe(1);
expect($totals['trial'])->toBe(1);
expect($totals['overdue'])->toBeGreaterThanOrEqual(1); // overdue включает должника
expect($totals['leads_delivered'])->toBe(2);
// Привязки без тарифа (tariff_kind=null) → доход 0.0 (не null).
expect((float) $totals['earned_rub'])->toBe(0.0);
// balance_sum_rub = 1000 + 200 + (-50) = 1150
expect((float) $totals['balance_sum_rub'])->toBe(1150.0);
});
test('менеджер: totals.earned_rub — сумма комиссии по тарифам клиентов', function () {
$manager = ov_makeSalesUser('manager');
$tenant = ov_makeTenant('Комиссия', false, 'active', 1000.0);
// Привязка со снимком тарифа topup_step 20% (порог 0, бонус 0) → доход = пополнения × 20%.
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => null,
'tariff_kind' => 'topup_step',
'tariff_params' => ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]],
'assigned_at' => now(),
]);
// Оборот текущего месяца: 5 000 000 копеек = 50 000 ₽ (метрика oborot_rub).
DB::table('lead_charges')->insert([
'tenant_id' => $tenant->id,
'deal_id' => 1,
'deal_received_at' => now(),
'tier_no' => 1,
'price_per_lead_kopecks' => 5_000_000,
'charge_source' => 'rub',
'charged_at' => now(),
'created_at' => now(),
]);
// Пополнения 50 000 ₽ в текущем месяце → комиссия topup_step 20% = 10 000 ₽.
DB::table('balance_transactions')->insert([
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => '50000.00',
'amount_leads' => 0,
'balance_rub_after' => '50000.00',
'description' => 'test',
'created_at' => now(),
]);
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
expect((float) $response->json('totals.earned_rub'))->toBe(10000.0);
});
test('менеджер: attention содержит overdue-клиента', function () {
$manager = ov_makeSalesUser('manager');
// Overdue: баланс < 0 — всегда в attention
$tOverdue = ov_makeTenant('Должник', false, 'active', -100.0);
ov_assign($manager, $tOverdue);
$response = ov_getOverview($manager);
$response->assertOk();
$attention = $response->json('attention');
$attentionIds = array_column($attention, 'tenant_id');
expect($attentionIds)->toContain($tOverdue->id);
});
test('менеджер: attention содержит suspended-клиента', function () {
$manager = ov_makeSalesUser('manager');
$tSuspended = ov_makeTenant('Приостановлен', false, 'suspended', 0.0);
ov_assign($manager, $tSuspended);
$response = ov_getOverview($manager);
$response->assertOk();
$attention = $response->json('attention');
$attentionIds = array_column($attention, 'tenant_id');
expect($attentionIds)->toContain($tSuspended->id);
});
test('менеджер: top_clients — топ-4 по leads_delivered, desc', function () {
$manager = ov_makeSalesUser('manager');
// Создаём 5 клиентов и добавляем им разное кол-во лидов
$tenants = [];
for ($i = 0; $i < 5; $i++) {
$t = ov_makeTenant("Клиент $i");
ov_assign($manager, $t);
$tenants[$i] = $t;
}
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
// Лиды: [10, 7, 5, 3, 1]
$leadCounts = [10, 7, 5, 3, 1];
foreach ($leadCounts as $idx => $cnt) {
for ($j = 0; $j < $cnt; $j++) {
ov_insertDeal($tenants[$idx]->id, $nowStr);
}
}
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
$top = $response->json('top_clients');
expect($top)->toHaveCount(4);
// Первый — максимальное кол-во лидов (10)
expect($top[0]['leads_delivered'])->toBe(10);
expect($top[0]['tenant_id'])->toBe($tenants[0]->id);
// Убеждаемся в убывании
for ($i = 0; $i < 3; $i++) {
expect($top[$i]['leads_delivered'])->toBeGreaterThanOrEqual($top[$i + 1]['leads_delivered']);
}
});
test('начальник: видит агрегаты по ВСЕМ клиентам, включая незакреплённых', function () {
$head = ov_makeSalesUser('head');
$manager = ov_makeSalesUser('manager');
// Три тенанта, один из которых не закреплён ни за кем
$t1 = ov_makeTenant('Закреплён 1', false, 'active', 100.0);
$t2 = ov_makeTenant('Закреплён 2', true, 'active', 200.0);
$t3 = ov_makeTenant('Без менеджера', false, 'active', 300.0);
ov_assign($manager, $t1);
ov_assign($manager, $t2);
// t3 — не назначен никому
$response = ov_getOverview($head, ['scope' => 'department']);
$response->assertOk();
$totals = $response->json('totals');
// Начальник должен видеть как минимум эти 3 (может быть больше если есть другие тенанты в тест-БД)
expect($totals['clients_count'])->toBeGreaterThanOrEqual(3);
$attentionAndTop = [
...(array) $response->json('attention'),
...(array) $response->json('top_clients'),
];
$allIds = array_unique([
...array_column((array) $response->json('attention'), 'tenant_id'),
...array_column((array) $response->json('top_clients'), 'tenant_id'),
]);
// Хотя бы один из трёх тенантов должен присутствовать в ответе начальника
// (либо в attention, либо в top)
// Более строгая проверка — clients_count включает все 3
// Проверяем через баланс-сумму (≥600 — все три наших тенанта)
expect((float) $totals['balance_sum_rub'])->toBeGreaterThanOrEqual(600.0);
});
test('менеджер без клиентов: clients_count=0, пустые массивы', function () {
$manager = ov_makeSalesUser('manager');
// Создаём другого менеджера с тенантом — наш менеджер не должен его видеть
$otherManager = ov_makeSalesUser('manager');
$t = ov_makeTenant('Чужой', false, 'active', 500.0);
ov_assign($otherManager, $t);
$response = ov_getOverview($manager);
$response->assertOk();
$totals = $response->json('totals');
expect($totals['clients_count'])->toBe(0);
expect($totals['active'])->toBe(0);
expect($totals['trial'])->toBe(0);
expect($totals['overdue'])->toBe(0);
expect($totals['leads_delivered'])->toBe(0);
// Нет клиентов → доход 0.0.
expect((float) $totals['earned_rub'])->toBe(0.0);
expect($response->json('attention'))->toBeArray()->toHaveCount(0);
expect($response->json('top_clients'))->toBeArray()->toHaveCount(0);
});
test('structure: ответ содержит все обязательные ключи', function () {
$manager = ov_makeSalesUser('manager');
$response = ov_getOverview($manager);
$response->assertOk();
$response->assertJsonStructure([
'totals' => [
'clients_count',
'active',
'trial',
'overdue',
'balance_sum_rub',
'leads_delivered',
'oborot_rub',
'earned_rub',
],
'attention',
'top_clients',
]);
});
test('attention: поля tenant_id, organization_name, status, balance_rub, runway_days', function () {
$manager = ov_makeSalesUser('manager');
$tOverdue = ov_makeTenant('Должник', false, 'active', -200.0);
ov_assign($manager, $tOverdue);
$response = ov_getOverview($manager);
$response->assertOk();
$attention = $response->json('attention');
expect($attention)->toBeArray()->not->toHaveCount(0);
$first = $attention[0];
expect($first)->toHaveKey('tenant_id');
expect($first)->toHaveKey('organization_name');
expect($first)->toHaveKey('status');
expect($first)->toHaveKey('balance_rub');
expect($first)->toHaveKey('runway_days');
});
test('top_clients: поля tenant_id, organization_name, leads_delivered, oborot_rub', function () {
$manager = ov_makeSalesUser('manager');
$t = ov_makeTenant('Топ Клиент');
ov_assign($manager, $t);
$nowStr = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
ov_insertDeal($t->id, $nowStr);
$response = ov_getOverview($manager, ['period' => 'this']);
$response->assertOk();
$top = $response->json('top_clients');
expect($top)->toBeArray()->not->toHaveCount(0);
$first = $top[0];
expect($first)->toHaveKey('tenant_id');
expect($first)->toHaveKey('organization_name');
expect($first)->toHaveKey('leads_delivered');
expect($first)->toHaveKey('oborot_rub');
});
test('запрос без токена → 401', function () {
test()->getJson('/api/sales/overview')->assertUnauthorized();
});
// ── Task 6.3: сквозной период ────────────────────────────────────────────────
test('период меняет месячные метрики, но НЕ баланс (this vs prev)', function () {
$manager = ov_makeSalesUser('manager');
$tenant = ov_makeTenant('Клиент периода', false, 'active', 1000.0);
ov_assign($manager, $tenant);
// lead_charges — реальные колонки схемы (project_id в таблице нет).
$insertCharge = function (int $tenantId, string $dt, int $kopecks): void {
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'deal_id' => rand(1, 99999),
'deal_received_at' => $dt,
'tier_no' => 1,
'price_per_lead_kopecks' => $kopecks,
'charge_source' => 'rub',
'charged_at' => $dt,
'created_at' => $dt,
]);
};
// Текущий месяц: 1 лид, оборот 3 000 ₽ (300 000 копеек).
$thisMonth = CarbonImmutable::now('Europe/Moscow')->startOfMonth()->addDays(4)->format('Y-m-d H:i:s');
ov_insertDeal($tenant->id, $thisMonth);
$insertCharge($tenant->id, $thisMonth, 300_000);
// Прошлый месяц: 1 лид, оборот 5 000 ₽ (500 000 копеек). Партиции deals/lead_charges — в схеме.
$prevMonth = CarbonImmutable::now('Europe/Moscow')->subMonth()->startOfMonth()->addDays(4)->format('Y-m-d H:i:s');
ov_insertDeal($tenant->id, $prevMonth);
$insertCharge($tenant->id, $prevMonth, 500_000);
$this_ = ov_getOverview($manager, ['period' => 'this'])->assertOk()->json('totals');
$prev = ov_getOverview($manager, ['period' => 'prev'])->assertOk()->json('totals');
// Месячные метрики различаются по периоду.
expect($this_['leads_delivered'])->toBe(1);
expect($prev['leads_delivered'])->toBe(1);
expect((float) $this_['oborot_rub'])->toBe(3000.0);
expect((float) $prev['oborot_rub'])->toBe(5000.0);
// Баланс — НЕ зависит от периода (текущее состояние счёта).
expect((float) $this_['balance_sum_rub'])->toBe(1000.0);
expect((float) $prev['balance_sum_rub'])->toBe(1000.0);
});
// ── §21: пополнения за период ─────────────────────────────────────────────────
test('сводка отдаёт сумму пополнений за период', function () {
// Часы заморожены на 00:30 МСК: ошибка границы суток вылезает только в первые
// три часа. Без заморозки тест краснел ночью и зеленел днём на одном и том же
// коде — 01.08.2026 он так и попался, ровно после полуночи.
CarbonImmutable::setTestNow(CarbonImmutable::now('Europe/Moscow')->setTime(0, 30));
$mgr = SalesUser::create([
'name' => 'M '.uniqid(), 'email' => 'ov'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => true,
]);
$tenant = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $mgr->id, 'tenant_id' => $tenant->id,
'tariff_id' => null, 'tariff_kind' => null, 'tariff_params' => [], 'assigned_at' => now(),
]);
// Пополнение сегодня и одно позавчера — второе в период «сегодня» попасть не должно.
DB::table('balance_transactions')->insert([
'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => 7150, 'amount_leads' => 0,
'balance_rub_after' => 7150, 'description' => 'тест', 'created_at' => now(),
]);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => 999, 'amount_leads' => 0,
'balance_rub_after' => 999, 'description' => 'старое', 'created_at' => now()->subDays(2),
]);
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/overview?period=today');
$res->assertOk();
expect((float) $res->json('totals.topup_rub'))->toBe(7150.0);
});
test('произвольный период без дат → понятная ошибка, а не падение', function () {
$mgr = SalesUser::create([
'name' => 'M '.uniqid(), 'email' => 'ov'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => true,
]);
$this->actingAs($mgr, 'sales')->getJson('/api/sales/overview?period=custom')
->assertStatus(422);
$this->actingAs($mgr, 'sales')->getJson('/api/sales/clients?period=custom')
->assertStatus(422);
});
test('список клиентов отдаёт пополнения по каждому клиенту за период', function () {
// Та же заморозка на 00:30 МСК, что и в тесте сводки выше, и по той же причине.
CarbonImmutable::setTestNow(CarbonImmutable::now('Europe/Moscow')->setTime(0, 30));
$mgr = SalesUser::create([
'name' => 'M '.uniqid(), 'email' => 'ov'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => 'manager', 'is_active' => true,
]);
$tenant = Tenant::factory()->create();
SalesClientAssignment::create([
'sales_user_id' => $mgr->id, 'tenant_id' => $tenant->id,
'tariff_id' => null, 'tariff_kind' => null, 'tariff_params' => [], 'assigned_at' => now(),
]);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => 5000, 'amount_leads' => 0,
'balance_rub_after' => 5000, 'description' => 'тест', 'created_at' => now(),
]);
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/clients?period=today');
$res->assertOk();
$row = collect($res->json('data'))->firstWhere('tenant_id', $tenant->id);
expect((float) $row['topup_rub'])->toBe(5000.0);
});
// ── строгое разделение ролей начальника (18.07.2026) ──────────────────────────
test('сводка начальника без scope считает только ЕГО клиентов', function () {
$head = ov_makeSalesUser('head');
$manager = ov_makeSalesUser('manager');
$mine = ov_makeTenant('Мой клиент', false, 'active', 1000.0);
$his = ov_makeTenant('Клиент менеджера', false, 'active', 2000.0);
ov_assign($head, $mine);
ov_assign($manager, $his);
// 🔴 Даём клиенту менеджера настоящие лиды. Список «топ-4» отбирается по числу
// лидов среди ВСЕХ клиентов в области видимости, а при запросе «весь отдел»
// область не ограничена ничем — берутся все тенанты базы. С нулём лидов наш
// клиент попадал в четвёрку только по удаче, и любой брошенный соседним тестом
// тенант его оттуда выдавливал: «ждали клиента в списке, а его нет» при
// исправном коде. С лидами место в четвёрке заслуженное, а не случайное.
// Замер 01.08.2026: в одиночку файл был зелёным, в общем прогоне падал.
ov_insertDeal($his->id, now()->toDateTimeString());
ov_insertDeal($his->id, now()->toDateTimeString());
$ids = fn (array $params) => array_column(
(array) ov_getOverview($head, $params)->assertOk()->json('top_clients'),
'tenant_id',
);
expect($ids([]))->not->toContain($his->id);
expect($ids(['scope' => 'department']))->toContain($his->id);
});