diff --git a/app/app/Http/Controllers/Api/Sales/SalesTariffController.php b/app/app/Http/Controllers/Api/Sales/SalesTariffController.php new file mode 100644 index 00000000..b9ea863a --- /dev/null +++ b/app/app/Http/Controllers/Api/Sales/SalesTariffController.php @@ -0,0 +1,333 @@ +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} + */ + 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 $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} + */ + private function tariffRow(SalesTariff $tariff): array + { + return [ + 'id' => $tariff->id, + 'name' => $tariff->name, + 'kind' => $tariff->kind, + 'params' => $tariff->params, + ]; + } + + /** + * Строка менеджера с метриками периода и оценкой начисления. + * + * @return array + */ + 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); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index b949707e..fb61eaed 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -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 diff --git a/app/routes/web.php b/app/routes/web.php index 991ce113..250200d6 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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). diff --git a/app/tests/Feature/Sales/SalesTariffApiTest.php b/app/tests/Feature/Sales/SalesTariffApiTest.php new file mode 100644 index 00000000..f13fcbf0 --- /dev/null +++ b/app/tests/Feature/Sales/SalesTariffApiTest.php @@ -0,0 +1,436 @@ +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 $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 $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(); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 78c3587d..f2d1cc1f 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий.