8e64f65cac
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>
328 lines
13 KiB
PHP
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);
|
|
});
|