Files
portal/app/tests/Feature/Sales/SalesPayoutTest.php
T
Дмитрий 0426735ea6 feat(sales): выплаты — проведение, журнал, остаток
Task 4.1 — SalesPayoutService + SalesPayoutController.

- record: append-only запись в sales_payouts + письмо менеджеру
  (SalesPayoutRecordedMail). Журнал неизменяем (DB-триггер
  sales_payouts_no_mutate — покрыто тестами на UPDATE/DELETE → QueryException).
- remaining (head): по каждому менеджеру за период — начислено
  (forManager), выплачено за период (по дате paid_on), выплачено всего,
  остаток = начислено − выплачено(период); переплата не зажимается в 0.
- index: менеджер видит только свои выплаты, начальник — все.
- POST /payouts и /payouts/remaining — только head; валидация суммы/даты/
  менеджера.

Тесты 15/15 (вкл. append-only через savepoint), sales-набор 145/145,
Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:10:45 +03:00

345 lines
13 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,
]);
}
/** Готовая выплата в БД (через сервис-модель, минуя контроллер). */
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();
// Оклад 30 000 + процент 20% от оборота.
$manager = pay_manager(baseSalary: 30000.0);
$tenant = pay_tenant();
pay_assignment($manager, $tenant, 'percent_oborot', ['rate' => 20]);
// Оборот 50 000 ₽ = 5 000 000 копеек → комиссия 20% = 10 000.
pay_leadCharge($tenant->id, 5_000_000, pay_date(10));
// Начислено = оклад 30 000 + 10 000 = 40 000.
// Выплачено в периоде = 15 000. Остаток = 25 000.
pay_payout($manager->id, $head->id, '15000.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(40000.0)
->and((float) $row['paid_period_rub'])->toBe(15000.0)
->and((float) $row['paid_all_time_rub'])->toBe(20000.0)
->and((float) $row['remaining_rub'])->toBe(25000.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();
});