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('daily_salary', []); $this->actingAs($manager, 'sales')->getJson('/api/sales/tariffs') ->assertStatus(403) ->assertJson(['message' => 'Доступно только начальнику отдела.']); $this->actingAs($manager, 'sales') ->postJson('/api/sales/tariffs', ['name' => 'X', 'kind' => 'daily_salary', 'params' => []]) ->assertStatus(403); $this->actingAs($manager, 'sales') ->putJson("/api/sales/tariffs/{$tariff->id}", ['name' => 'X', 'kind' => 'daily_salary', 'params' => []]) ->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' => [ 'threshold' => 0, 'reward' => 0, '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 создаёт валидный daily_salary тариф (params {}) → 201', function () { $head = tar_head(); $response = $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [ 'name' => 'Оклад', 'kind' => 'daily_salary', 'params' => [], ]); $response->assertCreated() ->assertJsonPath('kind', 'daily_salary') ->assertJsonPath('name', 'Оклад'); $this->assertDatabaseHas('sales_tariffs', ['id' => $response->json('id'), 'kind' => 'daily_salary']); }); test('head создаёт валидный topup_step тариф с порогом+бонусом → 201', function () { $head = tar_head(); $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [ 'name' => 'Фикс-бонус', 'kind' => 'topup_step', 'params' => ['threshold' => 50000, 'reward' => 5000, 'periods' => []], ])->assertCreated()->assertJsonPath('params.reward', 5000); }); test('store: topup_step без threshold → 422', function () { $head = tar_head(); // threshold обязателен для topup_step — без него валидация падает. $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [ 'name' => 'Кривой', 'kind' => 'topup_step', 'params' => ['reward' => 0, 'periods' => []], ])->assertStatus(422); }); test('store: topup_step без reward → 422', function () { $head = tar_head(); // reward обязателен для topup_step — без него валидация падает. $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [ 'name' => 'Кривой', 'kind' => 'topup_step', 'params' => ['threshold' => 100], ])->assertStatus(422); }); test('store: kind вне списка (percent_oborot — устаревший) → 422', function () { $head = tar_head(); // percent_oborot выведен из обращения — правило in: отклоняет его. $this->actingAs($head, 'sales')->postJson('/api/sales/tariffs', [ 'name' => 'Устаревший', 'kind' => 'percent_oborot', 'params' => ['rate' => 10], ])->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('topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]); $response = $this->actingAs($head, 'sales')->putJson("/api/sales/tariffs/{$tariff->id}", [ 'name' => 'Обновлённый', 'kind' => 'topup_step', 'params' => ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 25]]], ]); $response->assertOk() ->assertJsonPath('name', 'Обновлённый') ->assertJsonPath('params.periods.0.rate', 25); $this->assertDatabaseHas('sales_tariffs', ['id' => $tariff->id, 'name' => 'Обновлённый']); }); test('update: смена kind → 422', function () { $head = tar_head(); $tariff = tar_tariff('topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => []]); $this->actingAs($head, 'sales')->putJson("/api/sales/tariffs/{$tariff->id}", [ 'name' => 'X', 'kind' => 'daily_salary', 'params' => [], ])->assertStatus(422); }); test('update: несуществующий тариф → 404', function () { $head = tar_head(); $this->actingAs($head, 'sales')->putJson('/api/sales/tariffs/999999', [ 'name' => 'X', 'kind' => 'topup_step', 'params' => ['threshold' => 0, 'reward' => 0, 'periods' => []], ])->assertStatus(404); }); test('update НЕ трогает snapshot уже привязанных клиентов', function () { $head = tar_head(); $manager = tar_manager(); $tenant = tar_tenant(); $tariff = tar_tariff('topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]); // snapshot клиента = rate 10 на момент привязки $snapshotParams = ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]; $assignment = SalesClientAssignment::create([ 'sales_user_id' => $manager->id, 'tenant_id' => $tenant->id, 'tariff_id' => $tariff->id, 'tariff_kind' => 'topup_step', 'tariff_params' => $snapshotParams, 'assigned_at' => Carbon::now('Europe/Moscow'), ]); $this->actingAs($head, 'sales')->putJson("/api/sales/tariffs/{$tariff->id}", [ 'name' => 'Изменён', 'kind' => 'topup_step', 'params' => ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 99]]], ])->assertOk(); $assignment->refresh(); // Снимок не тронут: ставка ступени осталась 10 (не 99), вид тарифа — topup_step. expect((float) $assignment->tariff_params['periods'][0]['rate'])->toBe(10.0) ->and((float) $assignment->tariff_params['threshold'])->toBe(0.0) ->and($assignment->tariff_kind)->toBe('topup_step'); }); // ── 4. assign ───────────────────────────────────────────────────────────────── test('head назначает тариф + оклад менеджеру → 200 и запись в БД', function () { $head = tar_head(); $manager = tar_manager(); // daily_salary: ставка ₽/день задаётся per-manager (base_salary_rub). $tariff = tar_tariff('daily_salary', []); $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('daily_salary', []); $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('topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]); $newTariff = tar_tariff('daily_salary', []); // Существующая привязка со снимком старого тарифа $oldSnapshot = ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 10]]]; $assignment = SalesClientAssignment::create([ 'sales_user_id' => $manager->id, 'tenant_id' => $tenant->id, 'tariff_id' => $oldTariff->id, 'tariff_kind' => 'topup_step', 'tariff_params' => $oldSnapshot, '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('topup_step') ->and((float) $assignment->tariff_params['periods'][0]['rate'])->toBe(10.0) ->and($assignment->tariff_id)->toBe($oldTariff->id); // А current_tariff_id менеджера — обновился $manager->refresh(); expect($manager->current_tariff_id)->toBe($newTariff->id); }); // ── 5. index ────────────────────────────────────────────────────────────────── test('head видит каталог из 2 kind и менеджеров со всеми полями', function () { $head = tar_head(); $tStep = tar_tariff('topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]], 'Ступень'); $tSalary = tar_tariff('daily_salary', [], 'Оклад'); // Менеджер с текущим тарифом topup_step (процент 20%) + клиент с пополнением. $manager = tar_manager(baseSalary: 30000.0, currentTariffId: $tStep->id); $tenant = tar_tenant(); tar_assignment($manager, $tenant, 'topup_step', ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]]); tar_topup($tenant->id, '50000.00'); // пополнения 50 000 ₽ → комиссия 20% = 10 000 tar_leadCharge($tenant->id, 5_000_000); // оборот 50 000 ₽ (для метрики oborot_rub) $response = $this->actingAs($head, 'sales')->getJson('/api/sales/tariffs?period=this'); $response->assertOk(); $tariffs = $response->json('tariffs'); expect($tariffs)->toHaveCount(2); $kinds = array_column($tariffs, 'kind'); expect($kinds)->toContain('topup_step')->toContain('daily_salary') ->not->toContain('percent_oborot')->not->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($tStep->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', ]); // topup_step: оклад НЕ начисляется. estimated = процент 20% от пополнений 50 000 = 10 000. // JSON целое число декодируется как int → сравниваем по значению. expect((float) $row['estimated_earned_rub'])->toBe(10000.0); }); test('index: менеджер без текущего тарифа → estimated_earned_rub = 0', function () { $head = tar_head(); // Оклад задан, но без текущего тарифа он не начисляется (оклад только у daily_salary). $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(0.0) ->and($row['current_tariff_id'])->toBeNull(); }); // ── 6. unauthenticated ──────────────────────────────────────────────────────── test('неаутентифицированный GET /api/sales/tariffs → 401', function () { $this->getJson('/api/sales/tariffs')->assertUnauthorized(); });