2026-07-02 20:58:55 +03:00
|
|
|
|
<?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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 18:51:41 +03:00
|
|
|
|
/** Пополнение (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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 20:58:55 +03:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// Менеджер А (активный) — 2 клиента с оборотом и пополнениями. Тариф topup_step 20%.
|
|
|
|
|
|
// Менеджеры без текущего тарифа → оклад не начисляется; доход = комиссия по снимкам.
|
2026-07-02 20:58:55 +03:00
|
|
|
|
$mgrA = dash_manager(baseSalary: 30000.0, isActive: true);
|
|
|
|
|
|
$tA1 = dash_tenant(['balance_rub' => 5000.0]);
|
|
|
|
|
|
$tA2 = dash_tenant(['balance_rub' => 2000.0]);
|
2026-07-05 18:51:41 +03:00
|
|
|
|
$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);
|
2026-07-02 20:58:55 +03:00
|
|
|
|
// Оборот 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));
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// Пополнения того же размера → комиссия topup_step = пополнения × 20%.
|
|
|
|
|
|
dash_topup($tA1->id, '50000.00', dash_date(10));
|
|
|
|
|
|
dash_topup($tA2->id, '10000.00', dash_date(11));
|
2026-07-02 20:58:55 +03:00
|
|
|
|
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// Менеджер B (в отпуске, is_active=false) — 1 клиент-overdue, без пополнений.
|
2026-07-02 20:58:55 +03:00
|
|
|
|
$mgrB = dash_manager(baseSalary: 20000.0, isActive: false);
|
|
|
|
|
|
$tB = dash_tenant(['balance_rub' => -1500.0]); // overdue: balance < 0
|
2026-07-05 18:51:41 +03:00
|
|
|
|
dash_assignment($mgrB, $tB, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]);
|
2026-07-02 20:58:55 +03:00
|
|
|
|
|
|
|
|
|
|
// Выплата менеджеру 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)
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// earned = комиссия topup_step 20% от пополнений (50 000 + 10 000) = 12 000. Оклада нет.
|
|
|
|
|
|
->and((float) $rowA['earned_rub'])->toBe(12000.0)
|
2026-07-02 20:58:55 +03:00
|
|
|
|
// 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)
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// earned B = комиссия 10% от 0 (нет пополнений у tB) = 0. Оклада нет.
|
|
|
|
|
|
->and((float) $rowB['earned_rub'])->toBe(0.0);
|
2026-07-02 20:58:55 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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([]);
|
|
|
|
|
|
});
|