Files
portal/app/tests/Feature/Sales/SalesEarningsServiceTest.php
T
Дмитрий 8e64f65cac feat(sales): движок расчёта дохода по тарифу (3 вида)
Task 3.1 — SalesEarningsService, денежное ядро портала продаж.
Доход считается по СНИМКУ тарифа клиента (assignment.tariff_kind/params),
не по live-тарифу менеджера.

- percent_oborot: оборот периода × ставка.
- fix_per_client: reward если накопленные пополнения (all-time) ≥ порога, иначе 0.
- topup_step: Σ по месяцам периода пополнений месяца × ставка ступени по сроку
  обслуживания (tenure = diffInMonths(assigned_at→месяц)+1); вне таблицы → 0.
- forManager: оклад × число месяцев + Σ по клиентам (round 2).
- perClient: детализация для экрана «Мой доход».

Переиспользует SalesMetricsService (целые копейки, полуоткрытые интервалы)
и SalesPeriodResolver. Тесты 12/12 (заморозка времени на середину месяца
против флейков границ + партиций balance_transactions). Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:41:59 +03:00

328 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\SalesClientAssignment;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Services\Sales\SalesEarningsService;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodRange;
use App\Services\Sales\SalesPeriodResolver;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* TDD: SalesEarningsService — расчёт дохода менеджера по СНИМКУ тарифа клиента.
*
* Task 3.1. Денежное ядро — корректность критична.
*
* 3 вида тарифа:
* percent_oborot — % от оборота клиента за период;
* fix_per_client — фикс-выплата при достижении порога накопленных пополнений;
* topup_step — Σ по месяцам: пополнения месяца × ставка ступени по сроку (tenure) клиента.
*
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
* Default-соединение = pgsql (liderra_testing). Глобально подключён SharesAdminPdo (Pest.php).
*
* ⚠️ ВРЕМЯ: diffInMonths капризен у границ месяца → замораживаем на середину (день 15).
* ⚠️ ПАРТИЦИИ: balance_transactions партиционирована помесячно динамически →
* topups сеем ТОЛЬКО в текущий месяц.
*/
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 (уникальные имена earn_* — Pest-функции глобальны) ───────────────
/** Дата/время в ТЕКУЩЕМ месяце МСК (день $day) — партиция всегда покрыта. */
function earn_btDate(int $day, string $time = '10:00:00'): string
{
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
->setDay($day)->format('Y-m-d').' '.$time;
}
/** SalesPeriodRange на весь текущий месяц МСК. */
function earn_thisMonthRange(): SalesPeriodRange
{
$start = CarbonImmutable::now('Europe/Moscow')->startOfMonth();
return new SalesPeriodRange(
$start->startOfDay(),
$start->endOfMonth()->setTime(23, 59, 59),
);
}
/** Свежий сервис с реальными зависимостями (контейнер). */
function earn_service(): SalesEarningsService
{
return new SalesEarningsService(
new SalesMetricsService,
new SalesPeriodResolver,
);
}
/** Тенант. */
function earn_makeTenant(): Tenant
{
return Tenant::factory()->create([
'balance_rub' => 0.0,
'delivered_in_month' => 0,
]);
}
/** Менеджер отдела продаж с окладом. */
function earn_makeSalesUser(float $baseSalary = 30000.0): SalesUser
{
return SalesUser::create([
'name' => 'Manager '.fake()->firstName(),
'email' => uniqid('mgr_').'@t.local',
'password' => Hash::make('secret-x'),
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => $baseSalary,
]);
}
/**
* Привязка клиента к менеджеру со снимком тарифа.
*
* @param array<string,mixed> $tariffParams
*/
function earn_assignmentWithTariff(
SalesUser $user,
Tenant $tenant,
?string $tariffKind,
array $tariffParams,
?Carbon $assignedAt = null,
): SalesClientAssignment {
return SalesClientAssignment::create([
'sales_user_id' => $user->id,
'tenant_id' => $tenant->id,
'tariff_kind' => $tariffKind,
'tariff_params' => $tariffParams,
'assigned_at' => $assignedAt ?? Carbon::now('Europe/Moscow')->subMonthsNoOverflow(2)->subDays(15),
]);
}
/** lead_charge (для оборота) — текущий месяц. */
function earn_insertLeadCharge(int $tenantId, int $kopecks, string $chargedAt): void
{
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'deal_id' => fake()->numberBetween(1, 99999),
'deal_received_at' => $chargedAt,
'tier_no' => 1,
'price_per_lead_kopecks' => $kopecks,
'charge_source' => 'rub',
'charged_at' => $chargedAt,
'created_at' => $chargedAt,
]);
}
/** balance_transaction (пополнение) — текущий месяц. */
function earn_insertBalanceTx(int $tenantId, string $amountRub, string $createdAt): void
{
DB::table('balance_transactions')->insert([
'tenant_id' => $tenantId,
'type' => 'topup',
'amount_rub' => $amountRub,
'amount_leads' => 0,
'balance_rub_after' => $amountRub,
'description' => 'test',
'created_at' => $createdAt,
]);
}
// ── 1. topup_step ────────────────────────────────────────────────────────────
test('topup_step: пополнения текущего месяца × ставка ступени по сроку клиента', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
// Клиент привязан ~2.5 мес назад → tenure текущего месяца ≈ 3 → ступень 1-3 = 10%.
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'periods' => [
['from' => 1, 'to' => 3, 'rate' => 10],
['from' => 4, 'to' => 6, 'rate' => 7],
['from' => 7, 'to' => 9, 'rate' => 5],
['from' => 10, 'to' => 12, 'rate' => 3],
['from' => 13, 'to' => 24, 'rate' => 1],
],
]);
// Пополнения текущего месяца = 30 000 ₽ → 10% = 3000 ₽.
earn_insertBalanceTx($tenant->id, '20000.00', earn_btDate(5));
earn_insertBalanceTx($tenant->id, '10000.00', earn_btDate(20));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(3000.0);
});
test('topup_step: tenure вне таблицы ступеней → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
// Привязан 30 мес назад → tenure ~31 → вне таблицы (макс to=24) → ставка 0.
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'periods' => [
['from' => 1, 'to' => 3, 'rate' => 10],
['from' => 13, 'to' => 24, 'rate' => 1],
],
], assignedAt: Carbon::now('Europe/Moscow')->subMonthsNoOverflow(30));
earn_insertBalanceTx($tenant->id, '50000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
test('topup_step: без пополнений в периоде → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'periods' => [['from' => 1, 'to' => 3, 'rate' => 10]],
]);
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
// ── 2. percent_oborot ────────────────────────────────────────────────────────
test('percent_oborot: rate=20 от оборота 50 000 ₽ → 10000.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'percent_oborot', ['rate' => 20]);
// 50 000 ₽ = 5 000 000 копеек в текущем месяце.
earn_insertLeadCharge($tenant->id, 5_000_000, earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(10000.0);
});
test('percent_oborot: нулевой оборот → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'percent_oborot', ['rate' => 20]);
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
// ── 3. fix_per_client ────────────────────────────────────────────────────────
test('fix_per_client: накопленные пополнения ≥ порога → выплата reward', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'fix_per_client', [
'threshold' => 50000,
'reward' => 5000,
]);
// Накопленные пополнения 60 000 ≥ 50 000 → 5000 ₽.
earn_insertBalanceTx($tenant->id, '60000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(5000.0);
});
test('fix_per_client: накопленные пополнения < порога → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'fix_per_client', [
'threshold' => 50000,
'reward' => 5000,
]);
// 40 000 < 50 000 → 0.
earn_insertBalanceTx($tenant->id, '40000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
// ── 4. forManager ────────────────────────────────────────────────────────────
test('forManager: оклад + Σ дохода по всем клиентам за период', function () {
$user = earn_makeSalesUser(baseSalary: 30000.0);
// Клиент 1: percent_oborot 20% от оборота 50 000 → 10 000.
$t1 = earn_makeTenant();
earn_assignmentWithTariff($user, $t1, 'percent_oborot', ['rate' => 20]);
earn_insertLeadCharge($t1->id, 5_000_000, earn_btDate(10));
// Клиент 2: fix_per_client порог 50 000 reward 5 000, накоплено 60 000 → 5 000.
$t2 = earn_makeTenant();
earn_assignmentWithTariff($user, $t2, 'fix_per_client', ['threshold' => 50000, 'reward' => 5000]);
earn_insertBalanceTx($t2->id, '60000.00', earn_btDate(12));
// Период = 1 месяц → оклад 30 000 + 10 000 + 5 000 = 45 000.
expect(earn_service()->forManager($user, earn_thisMonthRange()))->toBe(45000.0);
});
test('forManager: менеджер без клиентов → только оклад', function () {
$user = earn_makeSalesUser(baseSalary: 30000.0);
expect(earn_service()->forManager($user, earn_thisMonthRange()))->toBe(30000.0);
});
// ── 5. perClient ─────────────────────────────────────────────────────────────
test('perClient: строка на каждого клиента с earned_rub', function () {
$user = earn_makeSalesUser();
$t1 = earn_makeTenant();
earn_assignmentWithTariff($user, $t1, 'percent_oborot', ['rate' => 20]);
earn_insertLeadCharge($t1->id, 5_000_000, earn_btDate(10));
$t2 = earn_makeTenant();
earn_assignmentWithTariff($user, $t2, 'fix_per_client', ['threshold' => 50000, 'reward' => 5000]);
earn_insertBalanceTx($t2->id, '60000.00', earn_btDate(12));
$rows = earn_service()->perClient($user, earn_thisMonthRange());
expect($rows)->toHaveCount(2);
$byTenant = collect($rows)->keyBy('tenant_id');
expect($byTenant[$t1->id]['tariff_kind'])->toBe('percent_oborot')
->and($byTenant[$t1->id]['earned_rub'])->toBe(10000.0)
->and($byTenant[$t2->id]['tariff_kind'])->toBe('fix_per_client')
->and($byTenant[$t2->id]['earned_rub'])->toBe(5000.0)
->and($byTenant[$t2->id])->toHaveKey('topups_rub');
});
// ── 6. края ──────────────────────────────────────────────────────────────────
test('forAssignment: неизвестный tariff_kind → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'nonsense_kind', ['rate' => 99]);
earn_insertLeadCharge($tenant->id, 5_000_000, earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
test('forAssignment: tariff_kind = null → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, null, []);
earn_insertLeadCharge($tenant->id, 5_000_000, earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});