daa41bcecf
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный оклад daily_salary и процент от пополнений topup_step. Убраны percent_oborot и fix_per_client. Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает FK sales_users.current_tariff_id и sales_client_assignments.tariff_id, удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов возвращает 0 по default-ветке. Снимки привязок не трогаются. Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics, фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта, CHANGELOG схемы v8.61 и спека портала. Не на проде: ветка feat/sales-portal-demo. Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0. Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
293 lines
12 KiB
PHP
293 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api\Sales;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\SalesTariff;
|
|
use App\Models\SalesUser;
|
|
use App\Services\Sales\SalesEarningsService;
|
|
use App\Services\Sales\SalesMetricsService;
|
|
use App\Services\Sales\SalesPeriodRange;
|
|
use App\Services\Sales\SalesPeriodResolver;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* Портал продаж — каталог тарифов + назначение текущего тарифа менеджеру (Task 3.2a).
|
|
*
|
|
* GET /api/sales/tariffs — каталог активных тарифов + менеджеры с метриками
|
|
* POST /api/sales/tariffs — создать тариф-шаблон
|
|
* PUT /api/sales/tariffs/{id} — обновить name/params тарифа (kind менять нельзя)
|
|
* POST /api/sales/tariffs/assign — назначить менеджеру текущий тариф + оклад
|
|
*
|
|
* ВСЕ методы — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
|
|
*
|
|
* Денежная семантика (В12):
|
|
* - Тариф — это ШАБЛОН. Уже привязанные клиенты держат СНИМОК тарифа
|
|
* (SalesClientAssignment.tariff_kind / tariff_params) и НЕ следуют live-правкам.
|
|
* - update правит только шаблон; assign правит только current_tariff_id /
|
|
* base_salary_rub менеджера. Ни один из методов НЕ трогает
|
|
* sales_client_assignments.
|
|
*
|
|
* estimated_earned_rub — информационная ОЦЕНКА «если бы все клиенты менеджера
|
|
* были на его текущем тарифе за период». По факту доход идёт по снимку каждого
|
|
* клиента (SalesEarningsService::forAssignment).
|
|
*
|
|
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md Task 3.2 Step 1.
|
|
*/
|
|
class SalesTariffController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly SalesMetricsService $metrics,
|
|
private readonly SalesEarningsService $earnings,
|
|
private readonly SalesPeriodResolver $resolver,
|
|
) {}
|
|
|
|
/**
|
|
* GET /api/sales/tariffs — каталог активных тарифов + менеджеры с метриками периода.
|
|
*/
|
|
public function index(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'),
|
|
]);
|
|
|
|
$tariffs = SalesTariff::query()
|
|
->where('is_active', true)
|
|
->orderBy('id')
|
|
->get()
|
|
->map(fn (SalesTariff $t): array => [
|
|
'id' => $t->id,
|
|
'name' => $t->name,
|
|
'kind' => $t->kind,
|
|
'params' => $t->params,
|
|
])
|
|
->all();
|
|
|
|
$managers = SalesUser::query()
|
|
->where('role', 'manager')
|
|
->with(['assignments', 'currentTariff'])
|
|
->orderBy('name')
|
|
->get()
|
|
->map(fn (SalesUser $m): array => $this->managerRow($m, $range))
|
|
->all();
|
|
|
|
return response()->json([
|
|
'tariffs' => $tariffs,
|
|
'managers' => $managers,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/sales/tariffs — создать тариф-шаблон.
|
|
*/
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
|
return $resp;
|
|
}
|
|
|
|
$data = $this->validateTariff($request);
|
|
|
|
$tariff = SalesTariff::create([
|
|
'name' => $data['name'],
|
|
'kind' => $data['kind'],
|
|
'params' => $data['params'],
|
|
'is_active' => true,
|
|
]);
|
|
|
|
return response()->json($this->tariffRow($tariff), 201);
|
|
}
|
|
|
|
/**
|
|
* PUT/PATCH /api/sales/tariffs/{id} — обновить name/params (kind менять нельзя).
|
|
*
|
|
* Правит только шаблон-тариф; sales_client_assignments (snapshot) не трогаются.
|
|
*/
|
|
public function update(Request $request, int $id): JsonResponse
|
|
{
|
|
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
|
return $resp;
|
|
}
|
|
|
|
$tariff = SalesTariff::find($id);
|
|
if ($tariff === null) {
|
|
return response()->json(['message' => 'Тариф не найден.'], 404);
|
|
}
|
|
|
|
// Смена kind запрещена — иначе снимки/расчёты рассинхронизируются.
|
|
if ($request->input('kind') !== null && $request->input('kind') !== $tariff->kind) {
|
|
throw ValidationException::withMessages([
|
|
'kind' => 'Тип тарифа менять нельзя.',
|
|
]);
|
|
}
|
|
|
|
// Валидируем params по СУЩЕСТВУЮЩЕМУ kind (в запросе kind совпадает или отсутствует).
|
|
$request->merge(['kind' => $tariff->kind]);
|
|
$data = $this->validateTariff($request);
|
|
|
|
$tariff->update([
|
|
'name' => $data['name'],
|
|
'params' => $data['params'],
|
|
]);
|
|
|
|
return response()->json($this->tariffRow($tariff));
|
|
}
|
|
|
|
/**
|
|
* POST /api/sales/tariffs/assign — назначить менеджеру текущий тариф (+ оклад).
|
|
*
|
|
* Правит только current_tariff_id / base_salary_rub менеджера.
|
|
* НЕ трогает существующие sales_client_assignments (снимки клиентов).
|
|
*/
|
|
public function assign(Request $request): JsonResponse
|
|
{
|
|
if (($resp = $this->denyIfNotHead($request)) !== null) {
|
|
return $resp;
|
|
}
|
|
|
|
$data = $request->validate([
|
|
'manager_id' => [
|
|
'required',
|
|
'integer',
|
|
// Менеджер должен существовать И иметь роль manager (не head).
|
|
Rule::exists('sales_users', 'id')->where('role', 'manager'),
|
|
],
|
|
'tariff_id' => ['required', 'integer', Rule::exists('sales_tariffs', 'id')],
|
|
'base_salary_rub' => ['sometimes', 'numeric', 'min:0'],
|
|
]);
|
|
|
|
/** @var SalesUser $manager */
|
|
$manager = SalesUser::query()->findOrFail($data['manager_id']);
|
|
|
|
$manager->current_tariff_id = (int) $data['tariff_id'];
|
|
if (array_key_exists('base_salary_rub', $data)) {
|
|
$manager->base_salary_rub = (string) $data['base_salary_rub'];
|
|
}
|
|
$manager->save();
|
|
|
|
return response()->json([
|
|
'id' => $manager->id,
|
|
'name' => $manager->name,
|
|
'current_tariff_id' => $manager->current_tariff_id,
|
|
'base_salary_rub' => (float) $manager->base_salary_rub,
|
|
]);
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
/**
|
|
* Валидация тарифа: базовые поля + структура params по kind.
|
|
*
|
|
* @return array{name: string, kind: string, params: array<string,mixed>}
|
|
*/
|
|
private function validateTariff(Request $request): array
|
|
{
|
|
$base = $request->validate([
|
|
'name' => ['required', 'string', 'max:255'],
|
|
'kind' => ['required', Rule::in(['daily_salary', 'topup_step'])],
|
|
'params' => ['nullable', 'array'],
|
|
]);
|
|
|
|
$rules = match ($base['kind']) {
|
|
// Процент от пополнений с порогом + разовым бонусом. Ступени опциональны
|
|
// (можно завести чистый бонус-тариф). Все поля настраиваются начальником.
|
|
'topup_step' => [
|
|
'params.threshold' => ['required', 'numeric', 'min:0'],
|
|
'params.reward' => ['required', 'numeric', 'min:0'],
|
|
'params.periods' => ['nullable', 'array'],
|
|
'params.periods.*.from' => ['required', 'integer', 'min:1'],
|
|
'params.periods.*.to' => ['required', 'integer', 'gte:params.periods.*.from'],
|
|
'params.periods.*.rate' => ['required', 'numeric', 'min:0'],
|
|
],
|
|
// Суточный оклад: ставка ₽/день задаётся per-manager (assign → base_salary_rub),
|
|
// не в params тарифа. Сам тариф — маркер режима, params = {}.
|
|
'daily_salary' => [],
|
|
default => [],
|
|
};
|
|
|
|
$request->validate($rules);
|
|
|
|
/** @var array<string,mixed> $params */
|
|
$params = $base['params'] ?? [];
|
|
|
|
return [
|
|
'name' => (string) $base['name'],
|
|
'kind' => (string) $base['kind'],
|
|
'params' => $params,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{id: int, name: string, kind: string, params: array<string,mixed>}
|
|
*/
|
|
private function tariffRow(SalesTariff $tariff): array
|
|
{
|
|
return [
|
|
'id' => $tariff->id,
|
|
'name' => $tariff->name,
|
|
'kind' => $tariff->kind,
|
|
'params' => $tariff->params,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Строка менеджера с метриками периода и оценкой начисления.
|
|
*
|
|
* estimated_earned_rub — оценка «если бы все клиенты были на текущем тарифе менеджера»
|
|
* (для daily_salary = суточный оклад; для topup_step = Σ по клиентам) —
|
|
* SalesEarningsService::estimatedForCurrentTariff.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function managerRow(SalesUser $manager, SalesPeriodRange $range): array
|
|
{
|
|
$tenantIds = $manager->assignments->pluck('tenant_id')->all();
|
|
|
|
$topups = 0.0;
|
|
$oborot = 0.0;
|
|
foreach ($tenantIds as $tenantId) {
|
|
$topups += $this->metrics->topupsRub((int) $tenantId, $range);
|
|
$oborot += $this->metrics->oborotRub((int) $tenantId, $range);
|
|
}
|
|
|
|
return [
|
|
'id' => $manager->id,
|
|
'name' => $manager->name,
|
|
'current_tariff_id' => $manager->current_tariff_id,
|
|
'current_tariff_name' => $manager->currentTariff?->name,
|
|
'current_tariff_kind' => $manager->currentTariff?->kind,
|
|
'base_salary_rub' => (float) $manager->base_salary_rub,
|
|
'clients_count' => $manager->assignments->count(),
|
|
'topups_rub' => round($topups, 2),
|
|
'oborot_rub' => round($oborot, 2),
|
|
'estimated_earned_rub' => $this->earnings->estimatedForCurrentTariff($manager, $range),
|
|
];
|
|
}
|
|
}
|