Files
portal/app/tests/Feature/Sales/SalesDashboardTest.php
T
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный
оклад daily_salary и процент от пополнений topup_step. Убраны
percent_oborot и fix_per_client.

Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает
FK sales_users.current_tariff_id и sales_client_assignments.tariff_id,
удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов
возвращает 0 по default-ветке. Снимки привязок не трогаются.

Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics,
фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта,
CHANGELOG схемы v8.61 и спека портала.

Не на проде: ветка feat/sales-portal-demo.

Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0.

Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
2026-07-05 19:25:19 +03:00

281 lines
12 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([]);
});