Files
portal/app/tests/Feature/Sales/SalesDashboardTest.php
T
Дмитрий e9edb206c8 @
feat(sales): кабинет начальника — единый период (без 500) + пополнения баланса

Экраны начальника отстали от вчерашних правок кабинета менеджера (§16–§21).

1. Живой баг: «Произвольный» период до выбора дат ронял запрос в 500 на пяти
   точках из шести (сводка отдела, доход, результативность, выплаты, тарифы).
   Разбор периода вынесен в трейт ResolvesSalesPeriod: 422 вместо падения,
   период по умолчанию d30 вместо this — как показывает сам PeriodPicker.

2. Пополнения баланса (topup_rub) добавлены в dashboard/overview (kpi +
   строки менеджеров) и managers/performance. На экранах: плашка «Пополнили
   баланс» в сводке отдела и колонка «Пополнили ₽» в обеих таблицах
   результативности. Из подписей убрано «(мес)» — период больше не месяц.

3. «Воронка отдела» правок не потребовала: она рендерит те же доску и карточку,
   что экран менеджера, а карточка сама грузит журнал и сохраняет контакты.

Тесты: SalesPeriodRequestTest проходит по всем шести точкам сразу.
Pest 263/263, Vitest 1420/1420, vue-tsc чист.
Спека — §22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-07-18 15:20:44 +03:00

321 lines
13 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\LegalEntity;
use App\Models\SaasInvoice;
use App\Models\SalesAttachmentRequest;
use App\Models\SalesClientAssignment;
use App\Models\SalesPayout;
use App\Models\SalesUser;
use App\Models\Tenant;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* TDD: SalesDashboardController@overview — сводка отдела продаж за период (Task 6.1a, бэк).
*
* GET /api/sales/dashboard/overview → head: сводка по всему отделу за период
*
* Метод — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
*
* Аутентификация: $this->actingAs($user, 'sales').
* Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php).
* DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске.
*
* ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций.
* ⚠️ ПАРТИЦИИ: lead_charges / balance_transactions партиционированы помесячно
* динамически → сеем оборот/выплаты ТОЛЬКО в текущий месяц.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$now = CarbonImmutable::create(null, null, 15, 12, 0, 0, 'Europe/Moscow');
CarbonImmutable::setTestNow($now);
Carbon::setTestNow($now);
});
afterEach(function () {
CarbonImmutable::setTestNow();
Carbon::setTestNow();
});
// ── helpers (уникальный префикс dash_) ───────────────────────────────────────
function dash_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'dashhead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function dash_manager(float $baseSalary = 0.0, bool $isActive = true): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'dashmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => $isActive,
'base_salary_rub' => $baseSalary,
]);
}
/** @param array<string,float> $overrides */
function dash_tenant(array $overrides = []): Tenant
{
return Tenant::factory()->create(array_merge([
'balance_rub' => 0.0,
'delivered_in_month' => 0,
], $overrides));
}
/** @param array<string,mixed> $params */
function dash_assignment(SalesUser $user, Tenant $tenant, string $kind = 'percent_oborot', array $params = ['rate' => 20]): SalesClientAssignment
{
return SalesClientAssignment::create([
'sales_user_id' => $user->id,
'tenant_id' => $tenant->id,
'tariff_kind' => $kind,
'tariff_params' => $params,
'assigned_at' => Carbon::now('Europe/Moscow')->subMonthsNoOverflow(2)->subDays(15),
]);
}
/** Дата в ТЕКУЩЕМ месяце МСК (день $day, только Y-m-d). */
function dash_date(int $day): string
{
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
->setDay($day)->format('Y-m-d');
}
function dash_leadCharge(int $tenantId, int $kopecks, ?string $at = null): void
{
$dt = ($at ?? dash_date(10)).' 10:00:00';
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'deal_id' => fake()->numberBetween(1, 9_999_999),
'deal_received_at' => $dt,
'tier_no' => 1,
'price_per_lead_kopecks' => $kopecks,
'charge_source' => 'rub',
'charged_at' => $dt,
'created_at' => $dt,
]);
}
/** Пополнение (topup) в ТЕКУЩЕМ месяце МСК — для дохода по тарифу topup_step. */
function dash_topup(int $tenantId, string $amountRub, ?string $at = null): void
{
$dt = ($at ?? dash_date(10)).' 10:00:00';
DB::table('balance_transactions')->insert([
'tenant_id' => $tenantId,
'type' => 'topup',
'amount_rub' => $amountRub,
'amount_leads' => 0,
'balance_rub_after' => $amountRub,
'description' => 'test',
'created_at' => $dt,
]);
}
function dash_payout(int $managerId, int $headId, string $amount, string $paidOn): SalesPayout
{
return SalesPayout::create([
'sales_user_id' => $managerId,
'amount_rub' => $amount,
'paid_on' => $paidOn,
'comment' => null,
'created_by' => $headId,
]);
}
function dash_pendingAttachment(int $managerId): SalesAttachmentRequest
{
return SalesAttachmentRequest::create([
'sales_user_id' => $managerId,
'login_input' => 'dash-client-'.uniqid().'@example.com',
'status' => 'pending',
]);
}
function dash_issuedInvoice(int $tenantId): SaasInvoice
{
$le = LegalEntity::query()->where('is_default', true)->first()
?? LegalEntity::create([
'code' => 'mp_'.uniqid(), 'name' => 'ИП Лидерра', 'legal_form' => 'IP',
'inn' => '770000000099', 'is_default' => true,
]);
return SaasInvoice::create([
'tenant_id' => $tenantId, 'legal_entity_id' => $le->id,
'invoice_number' => 'СЧ-2026-'.substr(uniqid(), -6), 'payer_type' => 'legal',
'payer_name' => 'ООО Плательщик', 'payer_inn' => '5000000000',
'amount_net' => '1500.00', 'amount_total' => '1500.00',
'status' => SaasInvoice::STATUS_ISSUED, 'issued_at' => now(), 'expires_at' => now()->addDays(5),
]);
}
// ── 1. доступ: менеджер (не head) → 403 ──────────────────────────────────────
test('менеджер GET /api/sales/dashboard/overview → 403', function () {
$manager = dash_manager();
$this->actingAs($manager, 'sales')->getJson('/api/sales/dashboard/overview')
->assertStatus(403)
->assertJson(['message' => 'Доступно только начальнику отдела.']);
});
test('неаутентифицированный GET /api/sales/dashboard/overview → 401', function () {
$this->getJson('/api/sales/dashboard/overview')->assertUnauthorized();
});
// ── 2. head: полная сводка ───────────────────────────────────────────────────
test('head: overview отдаёт kpi / alerts / performance по всему отделу', function () {
$head = dash_head();
// Менеджер А (активный) — 2 клиента с оборотом и пополнениями. Тариф topup_step 20%.
// Менеджеры без текущего тарифа → оклад не начисляется; доход = комиссия по снимкам.
$mgrA = dash_manager(baseSalary: 30000.0, isActive: true);
$tA1 = dash_tenant(['balance_rub' => 5000.0]);
$tA2 = dash_tenant(['balance_rub' => 2000.0]);
$stepParams = ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]];
dash_assignment($mgrA, $tA1, 'topup_step', $stepParams);
dash_assignment($mgrA, $tA2, 'topup_step', $stepParams);
// Оборот A1 = 50 000 ₽ (5 000 000 коп), A2 = 10 000 ₽ (1 000 000 коп).
dash_leadCharge($tA1->id, 5_000_000, dash_date(10));
dash_leadCharge($tA2->id, 1_000_000, dash_date(11));
// Пополнения того же размера → комиссия topup_step = пополнения × 20%.
dash_topup($tA1->id, '50000.00', dash_date(10));
dash_topup($tA2->id, '10000.00', dash_date(11));
// Менеджер B (в отпуске, is_active=false) — 1 клиент-overdue, без пополнений.
$mgrB = dash_manager(baseSalary: 20000.0, isActive: false);
$tB = dash_tenant(['balance_rub' => -1500.0]); // overdue: balance < 0
dash_assignment($mgrB, $tB, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]);
// Выплата менеджеру A в текущем периоде.
dash_payout($mgrA->id, $head->id, '12000.00', dash_date(12));
// Выплата ВНЕ периода (прошлый месяц) — в paid_period НЕ входит.
dash_payout($mgrA->id, $head->id, '4000.00',
CarbonImmutable::now('Europe/Moscow')->subMonthNoOverflow()->format('Y-m-d'));
// 1 pending-заявка + 1 issued-счёт.
dash_pendingAttachment($mgrA->id);
dash_issuedInvoice($tA1->id);
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/dashboard/overview?period=this');
$response->assertOk();
$json = $response->json();
// ── kpi ──
$kpi = $json['kpi'];
expect($kpi['managers_count'])->toBe(2)
->and($kpi['managers_active'])->toBe(1)
->and($kpi['managers_vacation'])->toBe(1)
->and($kpi['clients_count'])->toBe(3) // 3 привязки
->and((float) $kpi['balance_sum_rub'])->toBe(5500.0) // 5000 + 2000 1500
->and((float) $kpi['oborot_rub'])->toBe(60000.0) // 50000 + 10000
->and((float) $kpi['paid_period_rub'])->toBe(12000.0); // только выплата в периоде
// balance_sum_rub — строка number_format(2)
expect($kpi['balance_sum_rub'])->toBe('5500.00');
// ── alerts ──
$alerts = $json['alerts'];
expect($alerts['invoices_awaiting'])->toBeGreaterThanOrEqual(1)
->and($alerts['attachments_pending'])->toBeGreaterThanOrEqual(1)
->and($alerts['clients_balance_problem'])->toBeGreaterThanOrEqual(1); // tB overdue
// ── performance ──
$perf = collect($json['performance']);
$rowA = $perf->firstWhere('manager_id', $mgrA->id);
$rowB = $perf->firstWhere('manager_id', $mgrB->id);
expect($rowA)->not->toBeNull()
->and($rowA['name'])->toBe($mgrA->name)
->and($rowA['clients_count'])->toBe(2)
->and((float) $rowA['oborot_rub'])->toBe(60000.0)
// earned = комиссия topup_step 20% от пополнений (50 000 + 10 000) = 12 000. Оклада нет.
->and((float) $rowA['earned_rub'])->toBe(12000.0)
// all-time выплаты A = 12 000 + 4 000 = 16 000.
->and((float) $rowA['paid_all_time_rub'])->toBe(16000.0)
->and($rowA)->toHaveKeys([
'manager_id', 'name', 'clients_count', 'leads_delivered',
'oborot_rub', 'paid_all_time_rub', 'earned_rub',
]);
expect($rowB)->not->toBeNull()
->and($rowB['clients_count'])->toBe(1)
// earned B = комиссия 10% от 0 (нет пополнений у tB) = 0. Оклада нет.
->and((float) $rowB['earned_rub'])->toBe(0.0);
});
// ── 3. пустой отдел → нули/пустые массивы ────────────────────────────────────
test('head: пустой отдел → нули и пустой performance', function () {
$head = dash_head();
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/dashboard/overview');
$response->assertOk();
$json = $response->json();
expect($json['kpi']['managers_count'])->toBe(0)
->and($json['kpi']['managers_active'])->toBe(0)
->and($json['kpi']['managers_vacation'])->toBe(0)
->and($json['kpi']['clients_count'])->toBe(0)
->and($json['kpi']['balance_sum_rub'])->toBe('0.00')
->and((float) $json['kpi']['oborot_rub'])->toBe(0.0)
->and((float) $json['kpi']['paid_period_rub'])->toBe(0.0)
->and($json['performance'])->toBe([]);
});
// ── 4. пополнения за период (плашка «Пополнили баланс») ──────────────────────
test('head: overview считает пополнения клиентов за период — суммарно и по менеджерам', function () {
$head = dash_head();
$mgrA = dash_manager();
$tA1 = dash_tenant();
$tA2 = dash_tenant();
dash_assignment($mgrA, $tA1);
dash_assignment($mgrA, $tA2);
dash_topup($tA1->id, '7150.00', dash_date(10));
dash_topup($tA2->id, '2850.00', dash_date(11));
$mgrB = dash_manager();
$tB = dash_tenant();
dash_assignment($mgrB, $tB);
// Клиент B — триал без движений по счёту: у Тани на бою ровно такой случай,
// и ноль здесь — правда, а не поломка.
$json = $this->actingAs($head, 'sales')
->getJson('/api/sales/dashboard/overview')
->assertOk()
->json();
expect((float) $json['kpi']['topup_rub'])->toBe(10000.0);
$perf = collect($json['performance']);
expect((float) $perf->firstWhere('manager_id', $mgrA->id)['topup_rub'])->toBe(10000.0)
->and((float) $perf->firstWhere('manager_id', $mgrB->id)['topup_rub'])->toBe(0.0);
});
test('head: пустой отдел → пополнения 0', function () {
$head = dash_head();
$json = $this->actingAs($head, 'sales')
->getJson('/api/sales/dashboard/overview')->assertOk()->json();
expect((float) $json['kpi']['topup_rub'])->toBe(0.0);
});