b694c21557
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>
149 lines
5.4 KiB
PHP
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;
|
|
}
|
|
}
|