Files
portal/app/tests/Feature/Sales/SalesEarningsServiceTest.php
T

449 lines
19 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Models\SalesClientAssignment;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Services\MonthlyPartitionManager;
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 — расчёт дохода менеджера (модель 03.07.2026: ДВА вида тарифа).
*
* Денежное ядро — корректность критична.
*
* daily_salary — суточный оклад: доход = ставка ₽/день × отработанные дни (со дня приёма).
* Уровень МЕНЕДЖЕРА (не клиента): forAssignment = 0; оклад начисляет forManager,
* если ТЕКУЩИЙ тариф менеджера = daily_salary. Ставка ₽/день = base_salary_rub.
* topup_step — процент от пополнений с ПОРОГОМ + РАЗОВЫМ БОНУСОМ (по каждому клиенту):
* • накопленные пополнения < порога → 0;
* • впервые достигли порога → разовый бонус (один раз за клиента);
* • с суммы СВЕРХ порога → процент по ступеням срока (tenure).
*
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
* Default-соединение = pgsql (liderra_testing). Глобально подключён SharesAdminPdo (Pest.php).
*
* ⚠️ ВРЕМЯ: diffInMonths капризен у границ месяца → замораживаем на середину (день 15).
* ⚠️ ПАРТИЦИИ: 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 (уникальные имена 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;
}
/** Начало ПРОШЛОГО месяца МСК. */
function earn_prevMonthStart(): CarbonImmutable
{
return CarbonImmutable::now('Europe/Moscow')->subMonthsNoOverflow(1)->startOfMonth();
}
/** Дата/время в ПРОШЛОМ месяце МСК (день $day). Требует созданной партиции. */
function earn_prevBtDate(int $day, string $time = '10:00:00'): string
{
return earn_prevMonthStart()->setDay($day)->format('Y-m-d').' '.$time;
}
/** Создаёт партицию balance_transactions на прошлый месяц (для cross-month тестов). */
function earn_ensurePrevPartition(): void
{
app(MonthlyPartitionManager::class)->ensureMonth('balance_transactions', earn_prevMonthStart());
}
/** SalesPeriodRange на весь текущий месяц МСК. */
function earn_thisMonthRange(): SalesPeriodRange
{
$start = CarbonImmutable::now('Europe/Moscow')->startOfMonth();
return new SalesPeriodRange(
$start->startOfDay(),
$start->endOfMonth()->setTime(23, 59, 59),
);
}
/** SalesPeriodRange на произвольный календарный месяц (для детерминированных тестов оклада). */
function earn_monthRange(int $year, int $month): SalesPeriodRange
{
$start = CarbonImmutable::create($year, $month, 1, 0, 0, 0, 'Europe/Moscow');
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_makeTariff(string $kind, array $params = []): SalesTariff
{
return SalesTariff::create([
'name' => $kind.' '.uniqid(),
'kind' => $kind,
'params' => $params,
'is_active' => true,
]);
}
/**
* Менеджер отдела продаж. base_salary_rub для daily_salary = ставка ₽/день.
* $createdAt (дата приёма) — переопределяет авто-created_at, если задан.
*/
function earn_makeSalesUser(
float $baseSalary = 0.0,
?int $currentTariffId = null,
?string $createdAt = null,
): SalesUser {
$user = 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,
'current_tariff_id' => $currentTariffId,
]);
if ($createdAt !== null) {
$user->created_at = CarbonImmutable::parse($createdAt.' 00:00:00', 'Europe/Moscow');
$user->save();
}
return $user;
}
/**
* Привязка клиента к менеджеру со снимком тарифа.
*
* @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),
]);
}
/** 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-3=10% 4-6=7% 7-9=5% 10-12=3% 13-24=1%. */
function earn_defaultSteps(): array
{
return [
['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],
];
}
// ── 1. topup_step: порог + бонус + процент сверх ─────────────────────────────
test('topup_step: пополнения ниже порога → 0 (ни бонуса, ни процента)', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 30000,
'reward' => 10000,
'periods' => earn_defaultSteps(),
]);
// 20 000 < порог 30 000 → бонус не начислен, ничего сверх порога → 0.
earn_insertBalanceTx($tenant->id, '20000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
test('topup_step: достижение порога → разовый бонус + процент со суммы СВЕРХ порога', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
// Привязан ~2.5 мес назад → tenure текущего месяца = 3 → ступень 1-3 = 10%.
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 30000,
'reward' => 10000,
'periods' => earn_defaultSteps(),
]);
// Пополнения 50 000: бонус 10 000 + процент со сверх-порога (20 000 × 10% = 2000) = 12 000.
earn_insertBalanceTx($tenant->id, '20000.00', earn_btDate(5));
earn_insertBalanceTx($tenant->id, '30000.00', earn_btDate(20));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(12000.0);
});
test('topup_step: ставка процента — по ступени срока клиента (сверх-порог × ставка ступени)', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
// Привязан ~4.5 мес назад → tenure = 5 → ступень 4-6 = 7%. Бонус 0 (изолируем процент).
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 30000,
'reward' => 0,
'periods' => earn_defaultSteps(),
], assignedAt: Carbon::now('Europe/Moscow')->subMonthsNoOverflow(4)->subDays(15));
// 50 000: сверх-порог 20 000 × 7% = 1400.
earn_insertBalanceTx($tenant->id, '50000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(1400.0);
});
test('topup_step: разовый бонус — ОДИН раз (порог пройден в прошлом месяце → бонуса нет)', function () {
earn_ensurePrevPartition();
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
// tenure текущего месяца = 3 → 10%.
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 30000,
'reward' => 10000,
'periods' => earn_defaultSteps(),
]);
// Прошлый месяц: 40 000 → порог уже пройден до начала периода.
earn_insertBalanceTx($tenant->id, '40000.00', earn_prevBtDate(10));
// Текущий месяц: 10 000 — всё сверх порога → 10% = 1000. Бонус НЕ повторяется.
earn_insertBalanceTx($tenant->id, '10000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(1000.0);
});
test('topup_step: порог 0 и бонус 0 → процент со всей суммы пополнений', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
// tenure = 3 → 10%.
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 0,
'reward' => 0,
'periods' => earn_defaultSteps(),
]);
earn_insertBalanceTx($tenant->id, '30000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(3000.0);
});
test('topup_step: без пополнений в периоде → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 30000,
'reward' => 10000,
'periods' => earn_defaultSteps(),
]);
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
// ── 2. daily_salary ──────────────────────────────────────────────────────────
test('daily_salary: forAssignment = 0 (оклад — уровень менеджера, не клиента)', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'daily_salary', []);
earn_insertBalanceTx($tenant->id, '99999.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
test('forManager: суточный оклад × отработанные дни СО ДНЯ ПРИЁМА', function () {
$tariff = earn_makeTariff('daily_salary');
// Приём 10.03.2026, ставка 1000 ₽/день. Период — март (31 день).
// Отработано 10..31 марта = 22 дня → 22 000.
$user = earn_makeSalesUser(baseSalary: 1000.0, currentTariffId: $tariff->id, createdAt: '2026-03-10');
expect(earn_service()->forManager($user, earn_monthRange(2026, 3)))->toBe(22000.0);
});
test('forManager: суточный оклад — принят до периода → полный период', function () {
$tariff = earn_makeTariff('daily_salary');
// Приём 01.02.2026 (до марта). Март = 31 день × 1000 = 31 000.
$user = earn_makeSalesUser(baseSalary: 1000.0, currentTariffId: $tariff->id, createdAt: '2026-02-01');
expect(earn_service()->forManager($user, earn_monthRange(2026, 3)))->toBe(31000.0);
});
// ── 3. forManager: текущий тариф определяет режим оплаты ──────────────────────
test('forManager: текущий тариф — процент → Σ по клиентам, оклада НЕТ', function () {
$tariff = earn_makeTariff('topup_step', [
'threshold' => 0,
'reward' => 0,
'periods' => earn_defaultSteps(),
]);
// base_salary 30000 задан, но НЕ начисляется (тариф не daily_salary).
$user = earn_makeSalesUser(baseSalary: 30000.0, currentTariffId: $tariff->id);
$tenant = earn_makeTenant();
earn_assignmentWithTariff($user, $tenant, 'topup_step', [
'threshold' => 0,
'reward' => 0,
'periods' => earn_defaultSteps(),
]);
// tenure 3 → 10%, пополнения 50 000 → 5000. Оклада нет.
earn_insertBalanceTx($tenant->id, '50000.00', earn_btDate(10));
expect(earn_service()->forManager($user, earn_thisMonthRange()))->toBe(5000.0);
});
test('forManager: без текущего тарифа → 0 (оклад только у daily_salary)', function () {
$user = earn_makeSalesUser(baseSalary: 30000.0);
expect(earn_service()->forManager($user, earn_thisMonthRange()))->toBe(0.0);
});
// ── 4. perClient ─────────────────────────────────────────────────────────────
test('perClient: строка на каждого клиента с earned_rub (topup_step)', function () {
$user = earn_makeSalesUser();
// Клиент 1: порог 30 000, бонус 10 000, tenure 3 → 10%. Пополнения 50 000 → 10000 + 2000 = 12000.
$t1 = earn_makeTenant();
earn_assignmentWithTariff($user, $t1, 'topup_step', [
'threshold' => 30000, 'reward' => 10000, 'periods' => earn_defaultSteps(),
]);
earn_insertBalanceTx($t1->id, '50000.00', earn_btDate(10));
// Клиент 2: без пополнений → 0.
$t2 = earn_makeTenant();
earn_assignmentWithTariff($user, $t2, 'topup_step', [
'threshold' => 30000, 'reward' => 10000, 'periods' => earn_defaultSteps(),
]);
$rows = earn_service()->perClient($user, earn_thisMonthRange());
expect($rows)->toHaveCount(2);
$byTenant = collect($rows)->keyBy('tenant_id');
expect($byTenant[$t1->id]['tariff_kind'])->toBe('topup_step')
->and($byTenant[$t1->id]['earned_rub'])->toBe(12000.0)
->and($byTenant[$t2->id]['earned_rub'])->toBe(0.0)
->and($byTenant[$t2->id])->toHaveKey('topups_rub');
});
// ── 5. края ──────────────────────────────────────────────────────────────────
test('forAssignment: неизвестный tariff_kind → 0.0', function () {
$user = earn_makeSalesUser();
$tenant = earn_makeTenant();
$a = earn_assignmentWithTariff($user, $tenant, 'percent_oborot', ['rate' => 99]);
earn_insertBalanceTx($tenant->id, '50000.00', 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_insertBalanceTx($tenant->id, '50000.00', earn_btDate(10));
expect(earn_service()->forAssignment($a, earn_thisMonthRange()))->toBe(0.0);
});
// ── 6. estimatedForCurrentTariff (оценка «если бы все клиенты на текущем тарифе») ──
test('estimatedForCurrentTariff: текущий daily_salary → суточный оклад (клиенты не влияют)', function () {
$tariff = earn_makeTariff('daily_salary');
$user = earn_makeSalesUser(baseSalary: 1000.0, currentTariffId: $tariff->id, createdAt: '2026-03-01');
// Клиент со снимком topup_step — на оценку daily_salary не влияет.
$t = earn_makeTenant();
earn_assignmentWithTariff($user, $t, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => earn_defaultSteps()]);
// Март 31 день × 1000 = 31 000.
expect(earn_service()->estimatedForCurrentTariff($user, earn_monthRange(2026, 3)))->toBe(31000.0);
});
test('estimatedForCurrentTariff: текущий topup_step → Σ по клиентам как на текущем тарифе', function () {
$tariff = earn_makeTariff('topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => earn_defaultSteps()]);
$user = earn_makeSalesUser(currentTariffId: $tariff->id);
// Снимок клиента другой (null), но оценка берёт ТЕКУЩИЙ тариф: tenure 3 → 10%.
$t = earn_makeTenant();
earn_assignmentWithTariff($user, $t, null, []);
earn_insertBalanceTx($t->id, '50000.00', earn_btDate(10));
expect(earn_service()->estimatedForCurrentTariff($user, earn_thisMonthRange()))->toBe(5000.0);
});
test('estimatedForCurrentTariff: без текущего тарифа → 0', function () {
$user = earn_makeSalesUser(baseSalary: 30000.0);
expect(earn_service()->estimatedForCurrentTariff($user, earn_thisMonthRange()))->toBe(0.0);
});