feat(sales): каталог тарифов + назначение текущего (бэк)

Task 3.2a — SalesTariffController (index/store/update/assign), только head.

- index: активные тарифы (3 вида) + менеджеры с метриками периода
  (пополнения/оборот/клиенты) и оценкой «начислено» текущим тарифом.
- store/update: валидация params по виду тарифа (topup_step.periods[],
  percent_oborot.rate, fix_per_client.threshold/reward); смена kind в
  update запрещена (422).
- assign: ставит менеджеру current_tariff_id + base_salary_rub («оклад+
  процент» = оклад на менеджере + percent_oborot тариф).
- В12: ни update, ни assign НЕ трогают снимки уже привязанных клиентов
  (sales_client_assignments) — покрыто тестами.

Роуты в группе admin-db/auth:sales/sales-portal. Тесты 19/19,
весь sales-набор 130/130, Larastan 0 (3 Pest-false-positive в baseline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-02 18:56:58 +03:00
parent 8e64f65cac
commit c7d1e1751a
5 changed files with 795 additions and 5 deletions
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesClientAssignment;
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 Carbon\Carbon;
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'),
]);
$months = max(1, count($this->resolver->monthsIn($range)));
$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, $months))
->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(['topup_step', 'percent_oborot', 'fix_per_client'])],
'params' => ['required', 'array'],
]);
$rules = match ($base['kind']) {
'topup_step' => [
'params.periods' => ['required', 'array', 'min:1'],
'params.periods.*.from' => ['required', 'integer', 'min:1'],
'params.periods.*.to' => ['required', 'integer', 'gte:params.periods.*.from'],
'params.periods.*.rate' => ['required', 'numeric', 'min:0'],
],
'percent_oborot' => [
'params.rate' => ['required', 'numeric', 'min:0'],
],
'fix_per_client' => [
'params.threshold' => ['required', 'numeric', 'min:0'],
'params.reward' => ['required', 'numeric', 'min:0'],
],
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,
];
}
/**
* Строка менеджера с метриками периода и оценкой начисления.
*
* @return array<string, mixed>
*/
private function managerRow(SalesUser $manager, SalesPeriodRange $range, int $months): 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,
'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->estimatedEarned($manager, $range, $months),
];
}
/**
* Оценка «если бы все клиенты менеджера были на его ТЕКУЩЕМ тарифе за период».
*
* Для каждого клиента строим ВРЕМЕННЫЙ (не сохранённый) assignment со снимком
* текущего тарифа менеджера и реальными tenant_id/assigned_at, зовём
* SalesEarningsService::forAssignment и суммируем. Для семейства «оклад+процент»
* (percent_oborot) добавляем оклад × число месяцев периода.
*
* Нет текущего тарифа только оклад × месяцы.
*/
private function estimatedEarned(SalesUser $manager, SalesPeriodRange $range, int $months): float
{
$tariff = $manager->currentTariff;
$salary = (float) $manager->base_salary_rub * $months;
if ($tariff === null) {
return round($salary, 2);
}
$bonus = 0.0;
foreach ($manager->assignments as $assignment) {
// Временный снимок: текущий тариф менеджера на реального клиента.
$probe = new SalesClientAssignment([
'sales_user_id' => $manager->id,
'tenant_id' => $assignment->tenant_id,
'tariff_kind' => $tariff->kind,
'tariff_params' => $tariff->params,
'assigned_at' => $assignment->assigned_at ?? Carbon::now('Europe/Moscow'),
]);
// assigned_at не заполнится через fillable ($timestamps=false, но поле fillable) —
// выставляем явно на случай, если каст не подхватит.
$probe->tenant_id = (int) $assignment->tenant_id;
$probe->assigned_at = $assignment->assigned_at;
$bonus += $this->earnings->forAssignment($probe, $range);
}
// Оклад начисляется только для семейства «оклад+процент».
$withSalary = $tariff->kind === 'percent_oborot' ? $salary : 0.0;
return round($withSalary + $bonus, 2);
}
}
+15
View File
@@ -3357,3 +3357,18 @@ parameters:
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesAttachmentApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 20
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Sales/SalesTariffApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesTariffApiTest.php
+7 -1
View File
@@ -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\SalesTariffController;
use Illuminate\Support\Facades\Route;
// Laravel 13 string-based lazy-loading контроллеров (Sprint 2 Phase A, O-stack-03).
@@ -250,7 +251,12 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales
Route::post('/attachments', [SalesAttachmentController::class, 'store']);
Route::get('/attachments', [SalesAttachmentController::class, 'index']);
Route::post('/attachments/{id}/decide', [SalesAttachmentController::class, 'decide'])->whereNumber('id');
// income, tariffs, payouts, invoices, managers, dashboard
// Task 3.2a: каталог тарифов + назначение текущего тарифа менеджеру (только head)
Route::get('/tariffs', [SalesTariffController::class, 'index']);
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
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
@@ -0,0 +1,436 @@
<?php
declare(strict_types=1);
use App\Models\SalesClientAssignment;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* TDD: SalesTariffController HTTP endpoints каталога тарифов портала продаж
* и назначения текущего тарифа менеджеру (Task 3.2a).
*
* GET /api/sales/tariffs каталог тарифов + менеджеры (только head)
* POST /api/sales/tariffs создать тариф (только head)
* PUT /api/sales/tariffs/{id} обновить тариф (только head)
* POST /api/sales/tariffs/assign назначить тариф менеджеру (только head)
*
* Аутентификация: $this->actingAs($user, 'sales').
* Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php),
* даёт cross-connection visibility под middleware admin-db.
* DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске.
*
* ⚠️ ВРЕМЯ: замораживаем на середину месяца (день 15) детерминизм periods/tenure.
* ⚠️ ПАРТИЦИИ: 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 (уникальный префикс tar_) ────────────────────────────────────────
function tar_head(): SalesUser
{
return SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'tarhead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
}
function tar_manager(float $baseSalary = 0.0, ?int $currentTariffId = null): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'tarmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => $baseSalary,
'current_tariff_id' => $currentTariffId,
]);
}
function tar_tenant(): Tenant
{
return Tenant::factory()->create([
'balance_rub' => 0.0,
'delivered_in_month' => 0,
]);
}
/** @param array<string,mixed> $params */
function tar_tariff(string $kind, array $params, string $name = 'Тариф'): SalesTariff
{
return SalesTariff::create([
'name' => $name.' '.uniqid(),
'kind' => $kind,
'params' => $params,
'is_active' => true,
]);
}
/** @param array<string,mixed> $params */
function tar_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). */
function tar_date(int $day, string $time = '10:00:00'): string
{
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
->setDay($day)->format('Y-m-d').' '.$time;
}
function tar_topup(int $tenantId, string $amountRub, ?string $at = null): void
{
$dt = $at ?? tar_date(10);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenantId,
'type' => 'topup',
'amount_rub' => $amountRub,
'amount_leads' => 0,
'balance_rub_after' => $amountRub,
'description' => 'test',
'created_at' => $dt,
]);
}
function tar_leadCharge(int $tenantId, int $kopecks, ?string $at = null): void
{
$dt = $at ?? tar_date(10);
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,
]);
}
// ── 1. доступ: только head ────────────────────────────────────────────────────
test('manager получает 403 на всех 4 эндпоинтах тарифов', function () {
$manager = tar_manager();
$tariff = tar_tariff('percent_oborot', ['rate' => 20]);
$this->actingAs($manager, 'sales')->getJson('/api/sales/tariffs')
->assertStatus(403)
->assertJson(['message' => 'Доступно только начальнику отдела.']);
$this->actingAs($manager, 'sales')
->postJson('/api/sales/tariffs', ['name' => 'X', 'kind' => 'percent_oborot', 'params' => ['rate' => 5]])
->assertStatus(403);
$this->actingAs($manager, 'sales')
->putJson("/api/sales/tariffs/{$tariff->id}", ['name' => 'X', 'kind' => 'percent_oborot', 'params' => ['rate' => 5]])
->assertStatus(403);
$this->actingAs($manager, 'sales')
->postJson('/api/sales/tariffs/assign', ['manager_id' => $manager->id, 'tariff_id' => $tariff->id])
->assertStatus(403);
});
// ── 2. store ──────────────────────────────────────────────────────────────────
test('head создаёт валидный topup_step тариф → 201', function () {
$head = tar_head();
$response = $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Ступенчатый',
'kind' => 'topup_step',
'params' => ['periods' => [
['from' => 1, 'to' => 3, 'rate' => 10],
['from' => 4, 'to' => 12, 'rate' => 5],
]],
]);
$response->assertCreated();
expect($response->json('kind'))->toBe('topup_step')
->and($response->json('name'))->toBe('Ступенчатый')
->and($response->json('params.periods'))->toHaveCount(2);
$this->assertDatabaseHas('sales_tariffs', ['id' => $response->json('id'), 'kind' => 'topup_step']);
});
test('head создаёт валидный percent_oborot тариф → 201', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Процент',
'kind' => 'percent_oborot',
'params' => ['rate' => 20],
])->assertCreated()->assertJsonPath('params.rate', 20);
});
test('head создаёт валидный fix_per_client тариф → 201', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Фикс',
'kind' => 'fix_per_client',
'params' => ['threshold' => 50000, 'reward' => 5000],
])->assertCreated()->assertJsonPath('params.reward', 5000);
});
test('store: topup_step без periods → 422', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Кривой',
'kind' => 'topup_step',
'params' => ['foo' => 'bar'],
])->assertStatus(422);
});
test('store: percent_oborot без rate → 422', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Кривой',
'kind' => 'percent_oborot',
'params' => [],
])->assertStatus(422);
});
test('store: fix_per_client без reward → 422', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Кривой',
'kind' => 'fix_per_client',
'params' => ['threshold' => 100],
])->assertStatus(422);
});
test('store: неизвестный kind → 422', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [
'name' => 'Чужой',
'kind' => 'nonsense',
'params' => ['rate' => 1],
])->assertStatus(422);
});
// ── 3. update ─────────────────────────────────────────────────────────────────
test('head обновляет name/params тарифа → 200', function () {
$head = tar_head();
$tariff = tar_tariff('percent_oborot', ['rate' => 10]);
$response = $this->actingAs($head, 'sales')->putJson("/api/sales/tariffs/{$tariff->id}", [
'name' => 'Обновлённый',
'kind' => 'percent_oborot',
'params' => ['rate' => 25],
]);
$response->assertOk()
->assertJsonPath('name', 'Обновлённый')
->assertJsonPath('params.rate', 25);
$this->assertDatabaseHas('sales_tariffs', ['id' => $tariff->id, 'name' => 'Обновлённый']);
});
test('update: смена kind → 422', function () {
$head = tar_head();
$tariff = tar_tariff('percent_oborot', ['rate' => 10]);
$this->actingAs($head, 'sales')->putJson("/api/sales/tariffs/{$tariff->id}", [
'name' => 'X',
'kind' => 'fix_per_client',
'params' => ['threshold' => 1, 'reward' => 1],
])->assertStatus(422);
});
test('update: несуществующий тариф → 404', function () {
$head = tar_head();
$this->actingAs($head, 'sales')->putJson('/api/sales/tariffs/999999', [
'name' => 'X',
'kind' => 'percent_oborot',
'params' => ['rate' => 5],
])->assertStatus(404);
});
test('update НЕ трогает snapshot уже привязанных клиентов', function () {
$head = tar_head();
$manager = tar_manager();
$tenant = tar_tenant();
$tariff = tar_tariff('percent_oborot', ['rate' => 10]);
// snapshot клиента = rate 10 на момент привязки
$assignment = SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => $tariff->id,
'tariff_kind' => 'percent_oborot',
'tariff_params' => ['rate' => 10],
'assigned_at' => Carbon::now('Europe/Moscow'),
]);
$this->actingAs($head, 'sales')->putJson("/api/sales/tariffs/{$tariff->id}", [
'name' => 'Изменён',
'kind' => 'percent_oborot',
'params' => ['rate' => 99],
])->assertOk();
$assignment->refresh();
expect($assignment->tariff_params)->toBe(['rate' => 10])
->and($assignment->tariff_kind)->toBe('percent_oborot');
});
// ── 4. assign ─────────────────────────────────────────────────────────────────
test('head назначает тариф + оклад менеджеру → 200 и запись в БД', function () {
$head = tar_head();
$manager = tar_manager();
$tariff = tar_tariff('percent_oborot', ['rate' => 20]);
$response = $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs/assign', [
'manager_id' => $manager->id,
'tariff_id' => $tariff->id,
'base_salary_rub' => 35000,
]);
$response->assertOk()
->assertJsonPath('id', $manager->id)
->assertJsonPath('current_tariff_id', $tariff->id);
$manager->refresh();
expect($manager->current_tariff_id)->toBe($tariff->id)
->and((float) $manager->base_salary_rub)->toBe(35000.0);
});
test('assign: manager_id указывает на head → 422', function () {
$head = tar_head();
$otherHead = tar_head();
$tariff = tar_tariff('percent_oborot', ['rate' => 20]);
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs/assign', [
'manager_id' => $otherHead->id,
'tariff_id' => $tariff->id,
])->assertStatus(422);
});
test('assign НЕ меняет существующий snapshot assignment менеджера', function () {
$head = tar_head();
$manager = tar_manager();
$tenant = tar_tenant();
$oldTariff = tar_tariff('percent_oborot', ['rate' => 10]);
$newTariff = tar_tariff('fix_per_client', ['threshold' => 1000, 'reward' => 500]);
// Существующая привязка со снимком старого тарифа
$assignment = SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => $oldTariff->id,
'tariff_kind' => 'percent_oborot',
'tariff_params' => ['rate' => 10],
'assigned_at' => Carbon::now('Europe/Moscow'),
]);
// Назначаем менеджеру ДРУГОЙ текущий тариф
$this->actingAs($head, 'sales')->postJson('/api/sales/tariffs/assign', [
'manager_id' => $manager->id,
'tariff_id' => $newTariff->id,
])->assertOk();
// Снимок клиента не изменился
$assignment->refresh();
expect($assignment->tariff_kind)->toBe('percent_oborot')
->and($assignment->tariff_params)->toBe(['rate' => 10])
->and($assignment->tariff_id)->toBe($oldTariff->id);
// А current_tariff_id менеджера — обновился
$manager->refresh();
expect($manager->current_tariff_id)->toBe($newTariff->id);
});
// ── 5. index ──────────────────────────────────────────────────────────────────
test('head видит каталог из 3 kind и менеджеров со всеми полями', function () {
$head = tar_head();
$tStep = tar_tariff('topup_step', ['periods' => [['from' => 1, 'to' => 12, 'rate' => 5]]], 'Ступень');
$tPercent = tar_tariff('percent_oborot', ['rate' => 20], 'Процент');
tar_tariff('fix_per_client', ['threshold' => 50000, 'reward' => 5000], 'Фикс');
// Менеджер с текущим тарифом percent_oborot + оклад + клиент
$manager = tar_manager(baseSalary: 30000.0, currentTariffId: $tPercent->id);
$tenant = tar_tenant();
tar_assignment($manager, $tenant, 'percent_oborot', ['rate' => 20]);
tar_leadCharge($tenant->id, 5_000_000); // оборот 50 000 ₽
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/tariffs?period=this');
$response->assertOk();
$tariffs = $response->json('tariffs');
expect($tariffs)->toHaveCount(3);
$kinds = array_column($tariffs, 'kind');
expect($kinds)->toContain('topup_step')->toContain('percent_oborot')->toContain('fix_per_client');
$managers = $response->json('managers');
$row = collect($managers)->firstWhere('id', $manager->id);
expect($row)->not->toBeNull()
->and($row['current_tariff_id'])->toBe($tPercent->id)
->and($row['current_tariff_name'])->not->toBeNull()
->and($row['clients_count'])->toBe(1)
->and($row['estimated_earned_rub'])->toBeNumeric()
->and($row)->toHaveKeys([
'id', 'name', 'current_tariff_id', 'current_tariff_name',
'base_salary_rub', 'clients_count', 'topups_rub', 'oborot_rub', 'estimated_earned_rub',
]);
// estimated = оклад(30000) + процент 20% от оборота 50 000 (10 000) = 40 000
// JSON целое число декодируется как int → сравниваем по значению.
expect((float) $row['estimated_earned_rub'])->toBe(40000.0);
});
test('index: менеджер без тарифа → estimated = оклад × месяцы', function () {
$head = tar_head();
$manager = tar_manager(baseSalary: 25000.0, currentTariffId: null);
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/tariffs');
$response->assertOk();
$row = collect($response->json('managers'))->firstWhere('id', $manager->id);
expect($row)->not->toBeNull()
->and((float) $row['estimated_earned_rub'])->toBe(25000.0)
->and($row['current_tariff_id'])->toBeNull();
});
// ── 6. unauthenticated ────────────────────────────────────────────────────────
test('неаутентифицированный GET /api/sales/tariffs → 401', function () {
$this->getJson('/api/sales/tariffs')->assertUnauthorized();
});
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-07-02T15:40:01.021Z
Last updated: 2026-07-02T15:42:12.196Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 9812 | Code | 9.72ч | 0.0ч |
| 3412 | MsMpEng | 9.19ч | 0.0ч |
| 4 | System | 2.49ч | NaNч |
| 9812 | Code | 9.73ч | 0.0ч |
| 3412 | MsMpEng | 9.20ч | 0.0ч |
| 4 | System | 2.50ч | 14476770.2ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.