0426735ea6
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>
345 lines
13 KiB
PHP
345 lines
13 KiB
PHP
<?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();
|
||
});
|