Files
portal/app/tests/Feature/Sales/SalesTariffApiTest.php
T
Дмитрий c7d1e1751a 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>
2026-07-02 18:56:58 +03:00

437 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
});