Files
portal/app/tests/Feature/Sales/SalesIncomeTest.php
T
Дмитрий c77495af21 feat(sales): сводка «Мой доход» менеджера (бэк)
Task 4.2a — SalesIncomeController@show для авторизованного менеджера:
- totals: оборот клиентов (Σ), начислено за период (forManager),
  выплачено всего, к выплате за период (остаток начислено−выплачено).
- per_client: строки «Начислено по клиентам» (организация, тариф,
  пополнения, заработано) по снимкам тарифов.
- payouts: журнал СВОИХ выплат.
Head без клиентов → нули. Reuse earnings/metrics/payout сервисов.

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

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

238 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
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: SalesIncomeController@show — сводка «Мой доход» авторизованного менеджера (Task 4.2a).
*
* GET /api/sales/income → totals + per_client + payouts (журнал своих выплат)
*
* Аутентификация: $this->actingAs($user, 'sales').
* Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php).
* DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске.
*
* ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций.
* ⚠️ ПАРТИЦИИ: balance_transactions / lead_charges партиционированы помесячно
* динамически → сеем ТОЛЬКО в текущий месяц.
*/
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 (уникальный префикс inc_) ────────────────────────────────────────
function inc_manager(float $baseSalary = 0.0): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'incmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => $baseSalary,
]);
}
function inc_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'inchead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function inc_tenant(string $orgName): Tenant
{
return Tenant::factory()->create([
'organization_name' => $orgName,
'balance_rub' => 0.0,
'delivered_in_month' => 0,
]);
}
/** @param array<string,mixed> $params */
function inc_assignment(SalesUser $user, Tenant $tenant, string $kind, array $params): 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 inc_date(int $day): string
{
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
->setDay($day)->format('Y-m-d');
}
/** lead_charge (для оборота) — текущий месяц. */
function inc_leadCharge(int $tenantId, int $kopecks): void
{
$dt = inc_date(10).' 10:00:00';
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'deal_id' => fake()->numberBetween(1, 99999),
'deal_received_at' => $dt,
'tier_no' => 1,
'price_per_lead_kopecks' => $kopecks,
'charge_source' => 'rub',
'charged_at' => $dt,
'created_at' => $dt,
]);
}
/** Готовая выплата в БД. */
function inc_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,
]);
}
// ── 1. менеджер с 2 клиентами: totals + per_client ───────────────────────────
test('income: 2 клиента percent_oborot — totals.oborot + accrued + per_client', function () {
// Оклад 30 000. Клиент А: 20% от оборота 50 000 → 10 000. Клиент Б: 10% от 30 000 → 3 000.
$manager = inc_manager(baseSalary: 30000.0);
$tA = inc_tenant('ООО Альфа');
inc_assignment($manager, $tA, 'percent_oborot', ['rate' => 20]);
inc_leadCharge($tA->id, 5_000_000); // 50 000 ₽
$tB = inc_tenant('ООО Бета');
inc_assignment($manager, $tB, 'percent_oborot', ['rate' => 10]);
inc_leadCharge($tB->id, 3_000_000); // 30 000 ₽
$response = $this->actingAs($manager, 'sales')->getJson('/api/sales/income?period=this');
$response->assertOk();
// totals: оборот 50 000 + 30 000 = 80 000; начислено 30 000 + 10 000 + 3 000 = 43 000.
expect((float) $response->json('totals.oborot_rub'))->toBe(80000.0)
->and((float) $response->json('totals.accrued_rub'))->toBe(43000.0)
->and((float) $response->json('totals.paid_all_time_rub'))->toBe(0.0)
->and((float) $response->json('totals.to_pay_period_rub'))->toBe(43000.0);
$perClient = collect($response->json('per_client'))->keyBy('tenant_id');
expect($perClient)->toHaveCount(2);
expect($perClient[$tA->id]['organization_name'])->toBe('ООО Альфа')
->and($perClient[$tA->id]['tariff_kind'])->toBe('percent_oborot')
->and($perClient[$tA->id]['tariff_label'])->toBe('Оклад+процент')
->and((float) $perClient[$tA->id]['earned_rub'])->toBe(10000.0)
->and($perClient[$tA->id])->toHaveKey('topups_rub')
->and($perClient[$tB->id]['organization_name'])->toBe('ООО Бета')
->and((float) $perClient[$tB->id]['earned_rub'])->toBe(3000.0);
expect($response->json('payouts'))->toBe([]);
});
// ── 2. выплата в периоде: to_pay_period + paid_all_time + payouts ─────────────
test('income: проведённая выплата уменьшает to_pay_period, попадает в payouts', function () {
$head = inc_head();
$manager = inc_manager(baseSalary: 30000.0);
$tA = inc_tenant('ООО Гамма');
inc_assignment($manager, $tA, 'percent_oborot', ['rate' => 20]);
inc_leadCharge($tA->id, 5_000_000); // оборот 50 000 → комиссия 10 000
// Начислено = 30 000 + 10 000 = 40 000. Выплата в периоде 15 000.
inc_payout($manager->id, $head->id, '15000.00', inc_date(12));
$response = $this->actingAs($manager, 'sales')->getJson('/api/sales/income?period=this');
$response->assertOk();
expect((float) $response->json('totals.accrued_rub'))->toBe(40000.0)
->and((float) $response->json('totals.to_pay_period_rub'))->toBe(25000.0)
->and((float) $response->json('totals.paid_all_time_rub'))->toBe(15000.0);
$payouts = $response->json('payouts');
expect($payouts)->toHaveCount(1)
->and((float) $payouts[0]['amount_rub'])->toBe(15000.0)
->and($payouts[0]['sales_user_id'])->toBe($manager->id);
});
// ── 3. пустой менеджер: нули ──────────────────────────────────────────────────
test('income: менеджер без клиентов → per_client=[], totals нули, payouts=[]', function () {
$manager = inc_manager(baseSalary: 0.0);
$response = $this->actingAs($manager, 'sales')->getJson('/api/sales/income?period=this');
$response->assertOk()
->assertJsonPath('per_client', [])
->assertJsonPath('payouts', []);
expect((float) $response->json('totals.oborot_rub'))->toBe(0.0)
->and((float) $response->json('totals.accrued_rub'))->toBe(0.0)
->and((float) $response->json('totals.paid_all_time_rub'))->toBe(0.0)
->and((float) $response->json('totals.to_pay_period_rub'))->toBe(0.0);
});
// ── 4. payouts менеджера — только свои ────────────────────────────────────────
test('income: payouts содержит ТОЛЬКО выплаты этого менеджера', function () {
$head = inc_head();
$manager = inc_manager();
$other = inc_manager();
inc_payout($manager->id, $head->id, '1000.00', inc_date(5));
inc_payout($other->id, $head->id, '9999.00', inc_date(6));
$payouts = $this->actingAs($manager, 'sales')
->getJson('/api/sales/income?period=this')->json('payouts');
expect($payouts)->toHaveCount(1)
->and($payouts[0]['sales_user_id'])->toBe($manager->id);
});
// ── 5. head без клиентов → нули ───────────────────────────────────────────────
test('income: head без закреплённых клиентов → нули', function () {
$head = inc_head();
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/income?period=this');
$response->assertOk()
->assertJsonPath('per_client', []);
expect((float) $response->json('totals.accrued_rub'))->toBe(0.0);
});
// ── 6. unauthenticated ───────────────────────────────────────────────────────
test('income: неаутентифицированный GET /api/sales/income → 401', function () {
$this->getJson('/api/sales/income')->assertUnauthorized();
});