daa41bcecf
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный оклад daily_salary и процент от пополнений topup_step. Убраны percent_oborot и fix_per_client. Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает FK sales_users.current_tariff_id и sales_client_assignments.tariff_id, удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов возвращает 0 по default-ветке. Снимки привязок не трогаются. Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics, фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта, CHANGELOG схемы v8.61 и спека портала. Не на проде: ветка feat/sales-portal-demo. Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0. Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
449 lines
19 KiB
PHP
449 lines
19 KiB
PHP
<?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);
|
||
});
|