Files
portal/app/tests/Feature/Sales/SalesDashboardTest.php
T
Дмитрий 8778f41a12 feat(sales): сводка отдела (бэк)
Task 6.1a — SalesDashboardController@overview, только head.
- kpi: менеджеров (активных/в отпуске по is_active), клиентов закреплено,
  Σ баланс, оборот отдела за период, выплачено за период.
- alerts: счетов ждут оплаты (issued/overdue), заявок pending, клиентов
  с проблемой баланса (overdue/suspended/runway=0).
- performance: по каждому менеджеру — клиентов, лидов, оборот, выплачено
  всего, заработал (forManager за период).
Департамент-wide агрегаты; kopeck-точный оборот; выплаты по закрытому
интервалу paid_on.

Тесты 4/4, sales-набор 162/162, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:58:55 +03:00

261 lines
11 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,
]);
}
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();
// Менеджер А (активный, оклад 30 000) — 2 клиента с оборотом.
$mgrA = dash_manager(baseSalary: 30000.0, isActive: true);
$tA1 = dash_tenant(['balance_rub' => 5000.0]);
$tA2 = dash_tenant(['balance_rub' => 2000.0]);
dash_assignment($mgrA, $tA1, 'percent_oborot', ['rate' => 20]);
dash_assignment($mgrA, $tA2, 'percent_oborot', ['rate' => 20]);
// Оборот 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));
// Менеджер B (в отпуске, is_active=false, оклад 20 000) — 1 клиент-overdue.
$mgrB = dash_manager(baseSalary: 20000.0, isActive: false);
$tB = dash_tenant(['balance_rub' => -1500.0]); // overdue: balance < 0
dash_assignment($mgrB, $tB, 'percent_oborot', ['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 = оклад 30 000 × 1 мес + 20% от (50 000 + 10 000) = 30 000 + 12 000 = 42 000.
->and((float) $rowA['earned_rub'])->toBe(42000.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 = оклад 20 000 + 10% от 0 (нет оборота у tB) = 20 000.
->and((float) $rowB['earned_rub'])->toBe(20000.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([]);
});