Files
portal/app/app/Http/Controllers/Api/Sales/SalesTariffController.php
T
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 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>
2026-07-05 19:25:19 +03:00

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),
];
}
}