2026-07-02 17:36:00 +03:00
|
|
|
|
<?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]]);
|
2026-07-05 18:51:41 +03:00
|
|
|
|
// tariffB — валидный новый вид (используется только как НОВЫЙ текущий тариф менеджера;
|
|
|
|
|
|
// снимок привязки должен остаться от tariffA).
|
|
|
|
|
|
$tariffB = makeTariff(['kind' => 'daily_salary', 'params' => []]);
|
2026-07-02 17:36:00 +03:00
|
|
|
|
$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()]);
|
2026-07-05 18:51:41 +03:00
|
|
|
|
$tariffB = makeTariff(['name' => 'Тариф Б '.uniqid(), 'kind' => 'topup_step', 'params' => ['threshold' => 0, 'reward' => 2000, 'periods' => []]]);
|
2026-07-02 17:36:00 +03:00
|
|
|
|
|
|
|
|
|
|
$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)
|
2026-07-05 18:51:41 +03:00
|
|
|
|
->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([]);
|
2026-07-02 17:36:00 +03:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|