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>
263 lines
11 KiB
PHP
263 lines
11 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,
|
|
]);
|
|
}
|
|
|
|
/** Пополнение (topup) — текущий месяц. Доход по тарифу topup_step считается от пополнений. */
|
|
function inc_topup(int $tenantId, string $amountRub): void
|
|
{
|
|
$dt = inc_date(10).' 10:00:00';
|
|
DB::table('balance_transactions')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'type' => 'topup',
|
|
'amount_rub' => $amountRub,
|
|
'amount_leads' => 0,
|
|
'balance_rub_after' => $amountRub,
|
|
'description' => 'test',
|
|
'created_at' => $dt,
|
|
]);
|
|
}
|
|
|
|
/** topup_step с порогом 0, бонусом 0 и одной ступенью → процент со всех пополнений. */
|
|
function inc_stepParams(int $rate): array
|
|
{
|
|
return ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => $rate]]];
|
|
}
|
|
|
|
/** Готовая выплата в БД. */
|
|
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 клиента topup_step — totals.oborot + accrued + per_client', function () {
|
|
// Тариф topup_step (оклада нет). Клиент А: 20% от пополнений 50 000 → 10 000.
|
|
// Клиент Б: 10% от пополнений 30 000 → 3 000.
|
|
$manager = inc_manager(baseSalary: 30000.0);
|
|
|
|
$tA = inc_tenant('ООО Альфа');
|
|
inc_assignment($manager, $tA, 'topup_step', inc_stepParams(20));
|
|
inc_leadCharge($tA->id, 5_000_000); // оборот 50 000 ₽ (для метрики oborot_rub)
|
|
inc_topup($tA->id, '50000.00'); // пополнения 50 000 ₽ → комиссия 10 000
|
|
|
|
$tB = inc_tenant('ООО Бета');
|
|
inc_assignment($manager, $tB, 'topup_step', inc_stepParams(10));
|
|
inc_leadCharge($tB->id, 3_000_000); // оборот 30 000 ₽
|
|
inc_topup($tB->id, '30000.00'); // пополнения 30 000 ₽ → комиссия 3 000
|
|
|
|
$response = $this->actingAs($manager, 'sales')->getJson('/api/sales/income?period=this');
|
|
|
|
$response->assertOk();
|
|
|
|
// totals: оборот 50 000 + 30 000 = 80 000; начислено 10 000 + 3 000 = 13 000 (оклада нет).
|
|
expect((float) $response->json('totals.oborot_rub'))->toBe(80000.0)
|
|
->and((float) $response->json('totals.accrued_rub'))->toBe(13000.0)
|
|
->and((float) $response->json('totals.paid_all_time_rub'))->toBe(0.0)
|
|
->and((float) $response->json('totals.to_pay_period_rub'))->toBe(13000.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('topup_step')
|
|
->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, 'topup_step', inc_stepParams(20));
|
|
inc_leadCharge($tA->id, 5_000_000); // оборот 50 000 (метрика)
|
|
inc_topup($tA->id, '50000.00'); // пополнения 50 000 → комиссия 10 000
|
|
|
|
// Начислено = 10 000 (оклада нет). Выплата в периоде 6 000 → остаток 4 000.
|
|
inc_payout($manager->id, $head->id, '6000.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(10000.0)
|
|
->and((float) $response->json('totals.to_pay_period_rub'))->toBe(4000.0)
|
|
->and((float) $response->json('totals.paid_all_time_rub'))->toBe(6000.0);
|
|
|
|
$payouts = $response->json('payouts');
|
|
expect($payouts)->toHaveCount(1)
|
|
->and((float) $payouts[0]['amount_rub'])->toBe(6000.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();
|
|
});
|