Files
portal/app/tests/Feature/Sales/SalesPayoutTest.php
T
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 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>
2026-07-05 19:25:19 +03:00

361 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Mail\Sales\SalesPayoutRecordedMail;
use App\Models\SalesClientAssignment;
use App\Models\SalesPayout;
use App\Models\SalesUser;
use App\Models\Tenant;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* TDD: SalesPayoutController + SalesPayoutService — выплаты менеджерам (Task 4.1).
*
* POST /api/sales/payouts → head проводит выплату (append-only)
* GET /api/sales/payouts → журнал (менеджер — свои, head — все)
* GET /api/sales/payouts/remaining → head: начислено/выплачено/остаток по менеджерам
*
* Append-only: sales_payouts UPDATE/DELETE запрещены DB-триггером
* sales_payouts_no_mutate — журнал неизменяем.
*
* Аутентификация: $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 (уникальный префикс pay_) ────────────────────────────────────────
function pay_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'payhead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function pay_manager(float $baseSalary = 0.0, ?int $currentTariffId = null): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'paymgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => $baseSalary,
'current_tariff_id' => $currentTariffId,
]);
}
function pay_tenant(): Tenant
{
return Tenant::factory()->create([
'balance_rub' => 0.0,
'delivered_in_month' => 0,
]);
}
/** @param array<string,mixed> $params */
function pay_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 pay_date(int $day): string
{
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
->setDay($day)->format('Y-m-d');
}
function pay_leadCharge(int $tenantId, int $kopecks, ?string $at = null): void
{
$dt = ($at ?? pay_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 pay_topup(int $tenantId, string $amountRub, ?string $at = null): void
{
$dt = ($at ?? pay_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,
]);
}
/** Готовая выплата в БД (через сервис-модель, минуя контроллер). */
function pay_payout(int $managerId, int $headId, string $amount, string $paidOn, ?string $comment = null): SalesPayout
{
return SalesPayout::create([
'sales_user_id' => $managerId,
'amount_rub' => $amount,
'paid_on' => $paidOn,
'comment' => $comment,
'created_by' => $headId,
]);
}
// ── 1. store: доступ + happy path ────────────────────────────────────────────
test('менеджер POST /api/sales/payouts → 403', function () {
$manager = pay_manager();
$other = pay_manager();
$this->actingAs($manager, 'sales')->postJson('/api/sales/payouts', [
'manager_id' => $other->id,
'amount_rub' => 1000,
'paid_on' => pay_date(10),
])->assertStatus(403)
->assertJson(['message' => 'Доступно только начальнику отдела.']);
});
test('head проводит выплату → 201, запись в БД, письмо менеджеру', function () {
Mail::fake();
$head = pay_head();
$manager = pay_manager();
$response = $this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [
'manager_id' => $manager->id,
'amount_rub' => 12500.50,
'paid_on' => pay_date(10),
'comment' => 'Зарплата за период',
]);
$response->assertCreated()
->assertJsonPath('sales_user_id', $manager->id)
->assertJsonPath('manager_name', $manager->name)
->assertJsonPath('comment', 'Зарплата за период');
expect((float) $response->json('amount_rub'))->toBe(12500.50);
$this->assertDatabaseHas('sales_payouts', [
'sales_user_id' => $manager->id,
'amount_rub' => 12500.50,
'created_by' => $head->id,
]);
Mail::assertSent(SalesPayoutRecordedMail::class);
});
// ── 2. store: валидация ──────────────────────────────────────────────────────
test('store: manager_id указывает на head → 422', function () {
$head = pay_head();
$otherHead = pay_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [
'manager_id' => $otherHead->id,
'amount_rub' => 1000,
'paid_on' => pay_date(10),
])->assertStatus(422);
});
test('store: amount_rub = 0 → 422', function () {
$head = pay_head();
$manager = pay_manager();
$this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [
'manager_id' => $manager->id,
'amount_rub' => 0,
'paid_on' => pay_date(10),
])->assertStatus(422);
});
test('store: без paid_on → 422', function () {
$head = pay_head();
$manager = pay_manager();
$this->actingAs($head, 'sales')->postJson('/api/sales/payouts', [
'manager_id' => $manager->id,
'amount_rub' => 1000,
])->assertStatus(422);
});
// ── 3. append-only: журнал неизменяем ────────────────────────────────────────
test('append-only: UPDATE выплаты запрещён DB-триггером', function () {
$head = pay_head();
$manager = pay_manager();
$payout = pay_payout($manager->id, $head->id, '5000.00', pay_date(5));
// DB::transaction создаёт SAVEPOINT внутри DatabaseTransactions — при
// EXCEPTION триггера откатывается только savepoint, внешняя транзакция жива.
expect(fn () => DB::transaction(fn () => DB::table('sales_payouts')
->where('id', $payout->id)
->update(['amount_rub' => '9999.00'])))
->toThrow(QueryException::class);
// Значение в БД не изменилось.
$this->assertDatabaseHas('sales_payouts', [
'id' => $payout->id,
'amount_rub' => 5000.00,
]);
});
test('append-only: DELETE выплаты запрещён DB-триггером', function () {
$head = pay_head();
$manager = pay_manager();
$payout = pay_payout($manager->id, $head->id, '5000.00', pay_date(5));
expect(fn () => DB::transaction(fn () => DB::table('sales_payouts')
->where('id', $payout->id)->delete()))
->toThrow(QueryException::class);
$this->assertDatabaseHas('sales_payouts', ['id' => $payout->id]);
});
// ── 4. index: журнал (свой/все) ──────────────────────────────────────────────
test('index: менеджер видит ТОЛЬКО свои выплаты', function () {
$head = pay_head();
$manager = pay_manager();
$other = pay_manager();
pay_payout($manager->id, $head->id, '1000.00', pay_date(5));
pay_payout($manager->id, $head->id, '2000.00', pay_date(10));
pay_payout($other->id, $head->id, '3000.00', pay_date(7));
$response = $this->actingAs($manager, 'sales')->getJson('/api/sales/payouts');
$response->assertOk();
$data = $response->json('data');
expect($data)->toHaveCount(2);
$ids = array_column($data, 'sales_user_id');
expect(array_unique($ids))->toBe([$manager->id]);
});
test('index: начальник видит выплаты всех менеджеров с manager_name', function () {
$head = pay_head();
$m1 = pay_manager();
$m2 = pay_manager();
pay_payout($m1->id, $head->id, '1000.00', pay_date(5));
pay_payout($m2->id, $head->id, '2000.00', pay_date(10));
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/payouts');
$response->assertOk();
$data = $response->json('data');
// ≥2 — могут быть строки от параллельных helper-выплат нет (изоляция транзакцией).
expect(count($data))->toBeGreaterThanOrEqual(2);
$byUser = collect($data)->keyBy('sales_user_id');
expect($byUser->has($m1->id))->toBeTrue()
->and($byUser->has($m2->id))->toBeTrue()
->and($byUser[$m1->id]['manager_name'])->toBe($m1->name)
->and($byUser[$m1->id])->toHaveKeys([
'id', 'sales_user_id', 'manager_name', 'amount_rub',
'paid_on', 'comment', 'created_at', 'creator_name',
]);
});
// newest first
test('index: порядок newest first (paid_on desc)', function () {
$head = pay_head();
$manager = pay_manager();
pay_payout($manager->id, $head->id, '1000.00', pay_date(3));
pay_payout($manager->id, $head->id, '2000.00', pay_date(20));
$data = $this->actingAs($manager, 'sales')->getJson('/api/sales/payouts')->json('data');
expect($data[0]['paid_on'])->toBe(pay_date(20))
->and($data[1]['paid_on'])->toBe(pay_date(3));
});
// ── 5. remaining: начислено − выплачено ──────────────────────────────────────
test('remaining (head): accrued paid_period = остаток', function () {
$head = pay_head();
// Тариф topup_step 20% (оклада нет: у менеджера нет текущего тарифа daily_salary).
$manager = pay_manager(baseSalary: 30000.0);
$tenant = pay_tenant();
pay_assignment($manager, $tenant, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]]);
// Оборот 50 000 ₽ = 5 000 000 копеек (метрика oborot_rub).
pay_leadCharge($tenant->id, 5_000_000, pay_date(10));
// Пополнения 50 000 ₽ → комиссия topup_step 20% = 10 000.
pay_topup($tenant->id, '50000.00', pay_date(10));
// Начислено = 10 000 (оклада нет). Выплачено в периоде = 6 000. Остаток = 4 000.
pay_payout($manager->id, $head->id, '6000.00', pay_date(12));
// Выплата ВНЕ периода (прошлый месяц) — в all_time войдёт, в period — нет.
pay_payout($manager->id, $head->id, '5000.00',
CarbonImmutable::now('Europe/Moscow')->subMonthNoOverflow()->format('Y-m-d'));
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/payouts/remaining?period=this');
$response->assertOk();
$row = collect($response->json('data'))->firstWhere('manager_id', $manager->id);
expect($row)->not->toBeNull()
->and((float) $row['accrued_rub'])->toBe(10000.0)
->and((float) $row['paid_period_rub'])->toBe(6000.0)
->and((float) $row['paid_all_time_rub'])->toBe(11000.0)
->and((float) $row['remaining_rub'])->toBe(4000.0)
->and((float) $row['oborot_rub'])->toBe(50000.0)
->and($row['clients_count'])->toBe(1)
->and($row)->toHaveKeys([
'manager_id', 'name', 'clients_count', 'oborot_rub',
'accrued_rub', 'paid_period_rub', 'paid_all_time_rub', 'remaining_rub',
]);
});
test('remaining: менеджер (не head) → 403', function () {
$manager = pay_manager();
$this->actingAs($manager, 'sales')->getJson('/api/sales/payouts/remaining')
->assertStatus(403);
});
// ── 6. unauthenticated ───────────────────────────────────────────────────────
test('неаутентифицированный GET /api/sales/payouts → 401', function () {
$this->getJson('/api/sales/payouts')->assertUnauthorized();
});