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>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SalesUser;
|
||||
use App\Services\Sales\SalesMetricsService;
|
||||
use App\Services\Sales\SalesPayoutService;
|
||||
use App\Services\Sales\SalesPeriodResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* Портал продаж — выплаты менеджерам (Task 4.1).
|
||||
*
|
||||
* POST /api/sales/payouts — head проводит выплату (append-only).
|
||||
* GET /api/sales/payouts — журнал (менеджер — свои, head — все).
|
||||
* GET /api/sales/payouts/remaining — head: начислено/выплачено/остаток по менеджерам.
|
||||
*
|
||||
* store и remaining — ТОЛЬКО начальник (head). Менеджер → 403.
|
||||
* index — доступен и менеджеру (видит только свои выплаты).
|
||||
*
|
||||
* Денежная семантика: журнал sales_payouts append-only (DB-триггер запрещает
|
||||
* UPDATE/DELETE). remaining = начислено за период − выплачено в периоде (по дате
|
||||
* paid_on). Начисление считается по СНИМКУ тарифа клиента (SalesEarningsService).
|
||||
*/
|
||||
class SalesPayoutController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SalesPayoutService $payouts,
|
||||
private readonly SalesMetricsService $metrics,
|
||||
private readonly SalesPeriodResolver $resolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/sales/payouts — провести выплату менеджеру (только head).
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/** @var SalesUser $head */
|
||||
$head = $request->user('sales');
|
||||
|
||||
$data = $request->validate([
|
||||
'manager_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
// Менеджер должен существовать И иметь роль manager (не head).
|
||||
Rule::exists('sales_users', 'id')->where('role', 'manager'),
|
||||
],
|
||||
'amount_rub' => ['required', 'numeric', 'min:0.01'],
|
||||
'paid_on' => ['required', 'date'],
|
||||
'comment' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$payout = $this->payouts->record(
|
||||
$head,
|
||||
(int) $data['manager_id'],
|
||||
(string) $data['amount_rub'],
|
||||
(string) $data['paid_on'],
|
||||
$data['comment'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json($this->payouts->row($payout), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sales/payouts — журнал выплат.
|
||||
*
|
||||
* Менеджер → только свои; начальник → все.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
/** @var SalesUser $user */
|
||||
$user = $request->user('sales');
|
||||
|
||||
$rows = $this->payouts->journal($user, ownOnly: ! $user->isHead());
|
||||
|
||||
return response()->json(['data' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sales/payouts/remaining — остаток к выплате по менеджерам (только head).
|
||||
*/
|
||||
public function remaining(Request $request): JsonResponse
|
||||
{
|
||||
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
||||
return $resp;
|
||||
}
|
||||
|
||||
$range = $this->resolver->resolve([
|
||||
'kind' => (string) $request->query('period', 'this'),
|
||||
'from' => $request->query('from'),
|
||||
'to' => $request->query('to'),
|
||||
]);
|
||||
|
||||
$managers = SalesUser::query()
|
||||
->where('role', 'manager')
|
||||
->with('assignments')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$rows = $managers->map(function (SalesUser $manager) use ($range): array {
|
||||
$oborot = 0.0;
|
||||
foreach ($manager->assignments as $assignment) {
|
||||
$oborot += $this->metrics->oborotRub((int) $assignment->tenant_id, $range);
|
||||
}
|
||||
|
||||
$remaining = $this->payouts->remaining($manager, $range);
|
||||
|
||||
return [
|
||||
'manager_id' => $manager->id,
|
||||
'name' => $manager->name,
|
||||
'clients_count' => $manager->assignments->count(),
|
||||
'oborot_rub' => round($oborot, 2),
|
||||
'accrued_rub' => $remaining['accrued_rub'],
|
||||
'paid_period_rub' => $remaining['paid_period_rub'],
|
||||
'paid_all_time_rub' => $remaining['paid_all_time_rub'],
|
||||
'remaining_rub' => $remaining['remaining_rub'],
|
||||
];
|
||||
})->all();
|
||||
|
||||
return response()->json(['data' => $rows]);
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
|
||||
*/
|
||||
private function denyIfNotHead(Request $request): ?JsonResponse
|
||||
{
|
||||
/** @var SalesUser $user */
|
||||
$user = $request->user('sales');
|
||||
|
||||
if (! $user->isHead()) {
|
||||
return response()->json(['message' => 'Доступно только начальнику отдела.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Sales;
|
||||
|
||||
use App\Models\SalesPayout;
|
||||
use App\Models\SalesUser;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо менеджеру: проведена выплата (append-only журнал, Task 4.1).
|
||||
*
|
||||
* Отправляется при record() выплаты. Получателя устанавливает вызывающий код
|
||||
* через Mail::to($manager->email)->send(new ...).
|
||||
*/
|
||||
final class SalesPayoutRecordedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly SalesPayout $payout,
|
||||
public readonly SalesUser $manager,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Проведена выплата',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'mail.sales.payout-recorded',
|
||||
with: [
|
||||
'managerName' => $this->manager->name,
|
||||
'amountRub' => (float) $this->payout->amount_rub,
|
||||
'paidOn' => $this->payout->paid_on->format('d.m.Y'),
|
||||
'comment' => $this->payout->comment,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use App\Mail\Sales\SalesPayoutRecordedMail;
|
||||
use App\Models\SalesPayout;
|
||||
use App\Models\SalesUser;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Проведение и учёт выплат менеджерам отдела продаж (Task 4.1).
|
||||
*
|
||||
* Денежное ядро. Журнал sales_payouts — APPEND-ONLY: запись создаётся один раз,
|
||||
* UPDATE/DELETE запрещены DB-триггером sales_payouts_no_mutate. Здесь никакой
|
||||
* мутации существующих выплат нет и быть не может.
|
||||
*
|
||||
* remaining() — «остаток к выплате» = начислено (SalesEarningsService::forManager)
|
||||
* за период − уже выплачено В ЭТОМ ПЕРИОДЕ (по дате paid_on).
|
||||
*
|
||||
* Границы периода по выплатам считаются по ДАТЕ paid_on (тип date), не по времени:
|
||||
* paid_on >= range.start->toDateString() AND paid_on <= range.end->toDateString()
|
||||
* (закрытый интервал по календарным дням — paid_on без времени).
|
||||
*
|
||||
* Работает через DEFAULT connection (под middleware admin-db), как и остальные
|
||||
* sales-сервисы.
|
||||
*/
|
||||
class SalesPayoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SalesEarningsService $earnings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Провести выплату менеджеру: создать append-only запись + письмо менеджеру.
|
||||
*/
|
||||
public function record(
|
||||
SalesUser $head,
|
||||
int $managerId,
|
||||
float|string $amount,
|
||||
string $paidOn,
|
||||
?string $comment,
|
||||
): SalesPayout {
|
||||
$payout = SalesPayout::create([
|
||||
'sales_user_id' => $managerId,
|
||||
'amount_rub' => (string) $amount,
|
||||
'paid_on' => $paidOn,
|
||||
'comment' => $comment,
|
||||
'created_by' => $head->id,
|
||||
]);
|
||||
|
||||
// $timestamps=false → created_at заполняется DB-дефолтом, но не хидрируется
|
||||
// в свежую модель. Подтягиваем из БД, чтобы row() отдал created_at.
|
||||
$payout->refresh();
|
||||
|
||||
/** @var SalesUser $manager */
|
||||
$manager = SalesUser::query()->findOrFail($managerId);
|
||||
|
||||
Mail::to($manager->email)->send(new SalesPayoutRecordedMail($payout, $manager));
|
||||
|
||||
return $payout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Начислено / выплачено / остаток для менеджера за период.
|
||||
*
|
||||
* accrued_rub — начислено за период (SalesEarningsService::forManager).
|
||||
* paid_period_rub — Σ выплат с paid_on ∈ [range.start .. range.end] (по дате).
|
||||
* paid_all_time_rub — Σ всех выплат менеджера за всё время.
|
||||
* remaining_rub — accrued_rub − paid_period_rub (round 2), может быть < 0.
|
||||
*
|
||||
* @return array{accrued_rub: float, paid_period_rub: float, paid_all_time_rub: float, remaining_rub: float}
|
||||
*/
|
||||
public function remaining(SalesUser $manager, SalesPeriodRange $range): array
|
||||
{
|
||||
$accrued = $this->earnings->forManager($manager, $range);
|
||||
|
||||
$paidPeriod = (float) SalesPayout::query()
|
||||
->where('sales_user_id', $manager->id)
|
||||
->where('paid_on', '>=', $range->start->toDateString())
|
||||
->where('paid_on', '<=', $range->end->toDateString())
|
||||
->sum('amount_rub');
|
||||
|
||||
$paidAllTime = (float) SalesPayout::query()
|
||||
->where('sales_user_id', $manager->id)
|
||||
->sum('amount_rub');
|
||||
|
||||
return [
|
||||
'accrued_rub' => round($accrued, 2),
|
||||
'paid_period_rub' => round($paidPeriod, 2),
|
||||
'paid_all_time_rub' => round($paidAllTime, 2),
|
||||
'remaining_rub' => round($accrued - $paidPeriod, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Журнал выплат. $ownOnly (менеджер) → только свои; иначе (head) → все.
|
||||
* Порядок newest first: paid_on desc, id desc.
|
||||
*
|
||||
* @return list<array{id: int, sales_user_id: int, manager_name: string|null, amount_rub: float, paid_on: string, comment: string|null, created_at: string, creator_name: string|null}>
|
||||
*/
|
||||
public function journal(SalesUser $viewer, bool $ownOnly): array
|
||||
{
|
||||
$query = SalesPayout::query()
|
||||
->with(['salesUser', 'creator'])
|
||||
->orderByDesc('paid_on')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($ownOnly) {
|
||||
$query->where('sales_user_id', $viewer->id);
|
||||
}
|
||||
|
||||
return $query->get()
|
||||
->map(fn (SalesPayout $p): array => $this->row($p))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый формат строки выплаты (для journal и ответа store).
|
||||
*
|
||||
* @return array{id: int, sales_user_id: int, manager_name: string|null, amount_rub: float, paid_on: string, comment: string|null, created_at: string, creator_name: string|null}
|
||||
*/
|
||||
public function row(SalesPayout $payout): array
|
||||
{
|
||||
return [
|
||||
'id' => $payout->id,
|
||||
'sales_user_id' => $payout->sales_user_id,
|
||||
'manager_name' => $payout->salesUser?->name,
|
||||
'amount_rub' => (float) $payout->amount_rub,
|
||||
'paid_on' => $payout->paid_on->format('Y-m-d'),
|
||||
'comment' => $payout->comment,
|
||||
'created_at' => $payout->created_at->toIso8601String(),
|
||||
'creator_name' => $payout->creator?->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3372,3 +3372,18 @@ parameters:
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Sales/SalesTariffApiTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
path: tests/Feature/Sales/SalesPayoutTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Sales/SalesPayoutTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Sales/SalesPayoutTest.php
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<p>Здравствуйте, {{ $managerName }}!</p>
|
||||
<p>Вам проведена выплата на сумму <strong>{{ number_format($amountRub, 2, ',', ' ') }} ₽</strong>.</p>
|
||||
<p><strong>Дата выплаты:</strong> {{ $paidOn }}</p>
|
||||
@if($comment)
|
||||
<p><strong>Комментарий:</strong> {{ $comment }}</p>
|
||||
@endif
|
||||
<p>Подробности — в портале отдела продаж.</p>
|
||||
+6
-1
@@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Api\Sales\SalesAttachmentController;
|
||||
use App\Http\Controllers\Api\Sales\SalesAuthController;
|
||||
use App\Http\Controllers\Api\Sales\SalesClientsController;
|
||||
use App\Http\Controllers\Api\Sales\SalesPayoutController;
|
||||
use App\Http\Controllers\Api\Sales\SalesTariffController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -256,7 +257,11 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales
|
||||
Route::post('/tariffs', [SalesTariffController::class, 'store']);
|
||||
Route::match(['put', 'patch'], '/tariffs/{id}', [SalesTariffController::class, 'update'])->whereNumber('id');
|
||||
Route::post('/tariffs/assign', [SalesTariffController::class, 'assign']);
|
||||
// income, payouts, invoices, managers, dashboard
|
||||
// Task 4.1: выплаты менеджерам (append-only журнал). remaining/store — только head.
|
||||
Route::get('/payouts/remaining', [SalesPayoutController::class, 'remaining']);
|
||||
Route::get('/payouts', [SalesPayoutController::class, 'index']);
|
||||
Route::post('/payouts', [SalesPayoutController::class, 'store']);
|
||||
// income, invoices, managers, dashboard
|
||||
});
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
<?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();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-07-02T16:32:22.546Z
|
||||
Last updated: 2026-07-02T16:51:07.057Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 9812 | Code | 9.88ч | NaNч |
|
||||
| 3412 | MsMpEng | 9.79ч | 0.0ч |
|
||||
| 4 | System | 2.59ч | NaNч |
|
||||
| 9812 | Code | 9.96ч | 0.0ч |
|
||||
| 3412 | MsMpEng | 9.94ч | 1328032.9ч |
|
||||
| 4 | System | 2.62ч | NaNч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user