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

257 lines
11 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\Mail\Sales\AttachmentDecidedMail;
use App\Models\SalesAttachmentRequest;
use App\Models\SalesClientAssignment;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use App\Services\Sales\SalesAttachmentService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
/**
* TDD: SalesAttachmentService::decide() — решение начальника по заявке.
*
* Покрывает Task 2.2:
* - approve → создаёт assignment со snapshot тарифа менеджера
* - snapshot immutability (В12)
* - reject → status='rejected', comment, письмо, без assignment
* - reassign → старый assignment удалён, создан новый
* - manager decide → AuthorizationException
* - менеджер без тарифа → assignment без тарифа
*
* DB_DATABASE=liderra_testing; DatabaseTransactions — откат в конце теста.
*/
uses(DatabaseTransactions::class);
// ── helpers ──────────────────────────────────────────────────────────────────
function makeDecideManager(array $attrs = []): SalesUser
{
return SalesUser::create(array_merge([
'name' => 'Менеджер '.uniqid(),
'email' => 'mgr2'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
], $attrs));
}
function makeDecideHead(array $attrs = []): SalesUser
{
return SalesUser::create(array_merge([
'name' => 'Руководитель '.uniqid(),
'email' => 'head2'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
], $attrs));
}
function makeTariff(array $attrs = []): SalesTariff
{
return SalesTariff::create(array_merge([
'name' => 'Тариф '.uniqid(),
'kind' => 'topup_step',
'params' => ['step_rub' => 1000, 'reward_pct' => 5],
'is_active' => true,
], $attrs));
}
function makePendingRequest(SalesUser $manager, Tenant $tenant): SalesAttachmentRequest
{
return SalesAttachmentRequest::create([
'sales_user_id' => $manager->id,
'login_input' => $tenant->contact_email ?? 'client@test.local',
'tenant_id' => $tenant->id,
'status' => 'pending',
]);
}
function makeDecideTenant(): Tenant
{
return Tenant::factory()->create([
'contact_email' => 'client2'.uniqid().'@example.com',
]);
}
function getService(): SalesAttachmentService
{
return app(SalesAttachmentService::class);
}
// ── approve: свободный клиент ─────────────────────────────────────────────────
test('head approves pending request for free client — assignment created with tariff snapshot', function (): void {
Mail::fake();
$tariff = makeTariff();
$manager = makeDecideManager(['current_tariff_id' => $tariff->id]);
$head = makeDecideHead();
$tenant = makeDecideTenant();
$request = makePendingRequest($manager, $tenant);
$result = getService()->decide($head, $request->id, 'approve');
// Статус заявки изменён
expect($result->status)->toBe('approved')
->and($result->decided_by)->toBe($head->id)
->and($result->decided_at)->not->toBeNull();
// Assignment создан
$assignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($assignment)->not->toBeNull()
->and($assignment->sales_user_id)->toBe($manager->id)
->and($assignment->tariff_id)->toBe($tariff->id)
->and($assignment->tariff_kind)->toBe($tariff->kind)
->and($assignment->tariff_params)->toBe($tariff->params);
// Письмо поставлено в очередь
Mail::assertQueued(AttachmentDecidedMail::class, fn ($mail): bool => $mail->hasTo($manager->email));
});
// ── snapshot immutability (В12) ────────────────────────────────────────────────
test('snapshot immutability: changing manager tariff after approve does not affect existing assignment', function (): void {
Mail::fake();
$tariffA = makeTariff(['kind' => 'topup_step', 'params' => ['step_rub' => 500]]);
// tariffB — валидный новый вид (используется только как НОВЫЙ текущий тариф менеджера;
// снимок привязки должен остаться от tariffA).
$tariffB = makeTariff(['kind' => 'daily_salary', 'params' => []]);
$manager = makeDecideManager(['current_tariff_id' => $tariffA->id]);
$head = makeDecideHead();
$tenant = makeDecideTenant();
$request = makePendingRequest($manager, $tenant);
getService()->decide($head, $request->id, 'approve');
// Меняем тариф менеджера после одобрения
$manager->update(['current_tariff_id' => $tariffB->id]);
// Snapshot assignment-а НЕ должен был измениться
$assignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($assignment)->not->toBeNull()
->and($assignment->tariff_id)->toBe($tariffA->id)
->and($assignment->tariff_kind)->toBe('topup_step')
->and($assignment->tariff_params)->toBe(['step_rub' => 500]);
});
// ── reject ─────────────────────────────────────────────────────────────────────
test('head rejects pending request — status rejected, comment stored, no assignment, mail queued', function (): void {
Mail::fake();
$manager = makeDecideManager();
$head = makeDecideHead();
$tenant = makeDecideTenant();
$request = makePendingRequest($manager, $tenant);
$result = getService()->decide($head, $request->id, 'reject', 'Клиент уже обслуживается нами напрямую');
expect($result->status)->toBe('rejected')
->and($result->decided_by)->toBe($head->id)
->and($result->decided_at)->not->toBeNull()
->and($result->comment)->toBe('Клиент уже обслуживается нами напрямую');
// Assignment НЕ создан
$assignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($assignment)->toBeNull();
// Письмо об отказе поставлено в очередь
Mail::assertQueued(AttachmentDecidedMail::class, fn ($mail): bool => $mail->hasTo($manager->email));
});
// ── reassign ──────────────────────────────────────────────────────────────────
test('reassign: head approves manager B for client already assigned to manager A — old assignment gone, new one created', function (): void {
Mail::fake();
$tariffA = makeTariff(['name' => 'Тариф А '.uniqid()]);
$tariffB = makeTariff(['name' => 'Тариф Б '.uniqid(), 'kind' => 'topup_step', 'params' => ['threshold' => 0, 'reward' => 2000, 'periods' => []]]);
$managerA = makeDecideManager(['current_tariff_id' => $tariffA->id]);
$managerB = makeDecideManager(['current_tariff_id' => $tariffB->id]);
$head = makeDecideHead();
$tenant = makeDecideTenant();
// Клиент уже привязан к менеджеру A
SalesClientAssignment::create([
'sales_user_id' => $managerA->id,
'tenant_id' => $tenant->id,
'tariff_id' => $tariffA->id,
'tariff_kind' => $tariffA->kind,
'tariff_params' => $tariffA->params,
'assigned_at' => now(),
]);
// Менеджер B подал заявку на того же клиента
$requestB = makePendingRequest($managerB, $tenant);
$result = getService()->decide($head, $requestB->id, 'approve');
expect($result->status)->toBe('approved');
// Старый assignment менеджера A должен быть удалён
$oldAssignment = SalesClientAssignment::where('tenant_id', $tenant->id)
->where('sales_user_id', $managerA->id)
->first();
expect($oldAssignment)->toBeNull();
// Новый assignment создан для менеджера B
$newAssignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($newAssignment)->not->toBeNull()
->and($newAssignment->sales_user_id)->toBe($managerB->id)
->and($newAssignment->tariff_id)->toBe($tariffB->id)
->and($newAssignment->tariff_kind)->toBe('topup_step')
// Снимок = params tariffB (сравниваем по значениям — JSONB не хранит порядок ключей).
->and((float) $newAssignment->tariff_params['reward'])->toBe(2000.0)
->and((float) $newAssignment->tariff_params['threshold'])->toBe(0.0)
->and($newAssignment->tariff_params['periods'])->toBe([]);
// tenant_id UNIQUE — только одна запись
$count = SalesClientAssignment::where('tenant_id', $tenant->id)->count();
expect($count)->toBe(1);
Mail::assertQueued(AttachmentDecidedMail::class);
});
// ── менеджер не может решать ──────────────────────────────────────────────────
test('manager calling decide throws AuthorizationException', function (): void {
$manager = makeDecideManager();
$head = makeDecideHead();
$tenant = makeDecideTenant();
$request = makePendingRequest($manager, $tenant);
expect(fn () => getService()->decide($manager, $request->id, 'approve'))
->toThrow(AuthorizationException::class);
});
// ── менеджер без тарифа ───────────────────────────────────────────────────────
test('manager with current_tariff_id=null approved — assignment created with null tariff (income 0)', function (): void {
Mail::fake();
$manager = makeDecideManager(['current_tariff_id' => null]);
$head = makeDecideHead();
$tenant = makeDecideTenant();
$request = makePendingRequest($manager, $tenant);
$result = getService()->decide($head, $request->id, 'approve');
expect($result->status)->toBe('approved');
$assignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
expect($assignment)->not->toBeNull()
->and($assignment->tariff_id)->toBeNull()
->and($assignment->tariff_kind)->toBeNull()
->and($assignment->tariff_params)->toBe([]);
Mail::assertQueued(AttachmentDecidedMail::class);
});