Files
portal/app/tests/Feature/Sales/SalesTariffApiTest.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

457 lines
19 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('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();
});