Files
portal/app/app/Http/Controllers/Api/Sales/SalesPayoutController.php
T
Дмитрий b694c21557 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

149 lines
5.4 KiB
PHP

<?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;
}
}