feat(sales): решение начальника по заявке + снимок тарифа
Task 2.2: SalesAttachmentService.decide(head, requestId, approve|reject) — только начальник (иначе AuthorizationException). approve: создаёт sales_client_assignment со СНИМКОМ текущего тарифа менеджера (tariff_id/kind/params копируются значением на момент решения — В12: смена тарифа менеджера потом влияет только на новых клиентов, подтверждено тестом). Переназначение занятого клиента = delete+create в транзакции. reject: статус+comment. Письмо AttachmentDecidedMail менеджеру. Тест 6/6, весь sales 88/88, stan 0. Один эскейп на сессию. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Sales;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо менеджеру: его заявка на привязку клиента одобрена или отклонена.
|
||||
*
|
||||
* Отправляется decide() после принятия решения начальником.
|
||||
* Получатель устанавливается вызывающим кодом: Mail::to($email)->queue(new ...).
|
||||
*/
|
||||
final class AttachmentDecidedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
/** Имя менеджера, которому адресовано письмо */
|
||||
public readonly string $managerName,
|
||||
/** Логин клиента, указанный при подаче заявки */
|
||||
public readonly string $loginInput,
|
||||
/** Название организации клиента (или null если не найдено) */
|
||||
public readonly ?string $tenantName,
|
||||
/** 'approved' или 'rejected' */
|
||||
public readonly string $action,
|
||||
/** Название тарифа (только при action='approved' и если тариф есть) */
|
||||
public readonly ?string $tariffName,
|
||||
/** Комментарий руководителя (только при action='rejected') */
|
||||
public readonly ?string $comment,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->action === 'approved'
|
||||
? 'Ваша заявка одобрена'
|
||||
: 'Ваша заявка отклонена';
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'mail.sales.attachment-decided',
|
||||
with: [
|
||||
'managerName' => $this->managerName,
|
||||
'loginInput' => $this->loginInput,
|
||||
'tenantName' => $this->tenantName,
|
||||
'action' => $this->action,
|
||||
'tariffName' => $this->tariffName,
|
||||
'comment' => $this->comment,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use App\Mail\Sales\AttachmentDecidedMail;
|
||||
use App\Mail\Sales\AttachmentNotFoundMail;
|
||||
use App\Mail\Sales\AttachmentSubmittedMail;
|
||||
use App\Models\SalesAttachmentRequest;
|
||||
use App\Models\SalesClientAssignment;
|
||||
use App\Models\SalesTariff;
|
||||
use App\Models\SalesUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantRequisites;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -93,6 +96,50 @@ final class SalesAttachmentService
|
||||
return $this->handleFree($manager, $login, $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Решение начальника по заявке. Только role='head' — иначе бросить AuthorizationException.
|
||||
*
|
||||
* approve:
|
||||
* - Снимает snapshot текущего тарифа запрашивающего менеджера.
|
||||
* - Если клиент уже привязан к другому менеджеру (reassign) — удаляет старую привязку.
|
||||
* - Создаёт новую SalesClientAssignment с тарифным snapshot-ом.
|
||||
* - Отмечает заявку 'approved'. Ставит AttachmentDecidedMail в очередь.
|
||||
*
|
||||
* reject:
|
||||
* - Отмечает заявку 'rejected', сохраняет comment.
|
||||
* - Ставит AttachmentDecidedMail в очередь.
|
||||
*
|
||||
* @param string $action 'approve'|'reject'
|
||||
*
|
||||
* @throws AuthorizationException если $head не является начальником (role != 'head')
|
||||
* @throws \DomainException если заявка не в статусе 'pending'
|
||||
*/
|
||||
public function decide(
|
||||
SalesUser $head,
|
||||
int $requestId,
|
||||
string $action,
|
||||
?string $comment = null,
|
||||
): SalesAttachmentRequest {
|
||||
if (! $head->isHead()) {
|
||||
throw new AuthorizationException('Только начальник может принимать решение по заявке.');
|
||||
}
|
||||
|
||||
/** @var SalesAttachmentRequest $request */
|
||||
$request = SalesAttachmentRequest::findOrFail($requestId);
|
||||
|
||||
if ($request->status !== 'pending') {
|
||||
throw new \DomainException(
|
||||
'Нельзя принять решение по заявке со статусом «'.$request->status.'».'
|
||||
);
|
||||
}
|
||||
|
||||
if ($action === 'approve') {
|
||||
return $this->handleApprove($head, $request);
|
||||
}
|
||||
|
||||
return $this->handleReject($head, $request, $comment);
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function handleNotFound(SalesUser $manager, string $login): SalesAttachmentRequest
|
||||
@@ -206,4 +253,84 @@ final class SalesAttachmentService
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function handleApprove(SalesUser $head, SalesAttachmentRequest $request): SalesAttachmentRequest
|
||||
{
|
||||
/** @var SalesUser $manager */
|
||||
$manager = SalesUser::findOrFail($request->sales_user_id);
|
||||
|
||||
// Snapshot тарифа менеджера на момент одобрения
|
||||
$tariffId = $manager->current_tariff_id;
|
||||
$tariff = $tariffId !== null ? SalesTariff::find($tariffId) : null;
|
||||
|
||||
$tenantId = $request->tenant_id;
|
||||
|
||||
DB::transaction(static function () use ($head, $request, $manager, $tariff, $tariffId, $tenantId): void {
|
||||
// Reassignment: если клиент уже привязан к другому менеджеру — удаляем старую привязку
|
||||
if ($tenantId !== null) {
|
||||
SalesClientAssignment::where('tenant_id', $tenantId)->delete();
|
||||
}
|
||||
|
||||
// Создаём новую привязку со snapshot тарифа
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'tariff_id' => $tariffId,
|
||||
'tariff_kind' => $tariff !== null ? $tariff->kind : null,
|
||||
'tariff_params' => $tariff !== null ? $tariff->params : [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
// Помечаем заявку как одобренную
|
||||
$request->status = 'approved';
|
||||
$request->decided_by = $head->id;
|
||||
$request->decided_at = now();
|
||||
$request->save();
|
||||
});
|
||||
|
||||
$request->refresh();
|
||||
|
||||
// Уведомляем менеджера
|
||||
Mail::to($manager->email)->queue(new AttachmentDecidedMail(
|
||||
managerName: $manager->name,
|
||||
loginInput: $request->login_input,
|
||||
tenantName: $request->tenant?->organization_name,
|
||||
action: 'approved',
|
||||
tariffName: $tariff?->name,
|
||||
comment: null,
|
||||
));
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function handleReject(
|
||||
SalesUser $head,
|
||||
SalesAttachmentRequest $request,
|
||||
?string $comment,
|
||||
): SalesAttachmentRequest {
|
||||
/** @var SalesUser $manager */
|
||||
$manager = SalesUser::findOrFail($request->sales_user_id);
|
||||
|
||||
DB::transaction(static function () use ($head, $request, $comment): void {
|
||||
$request->status = 'rejected';
|
||||
$request->decided_by = $head->id;
|
||||
$request->decided_at = now();
|
||||
$request->comment = $comment;
|
||||
$request->save();
|
||||
});
|
||||
|
||||
$request->refresh();
|
||||
|
||||
// Уведомляем менеджера
|
||||
Mail::to($manager->email)->queue(new AttachmentDecidedMail(
|
||||
managerName: $manager->name,
|
||||
loginInput: $request->login_input,
|
||||
tenantName: $request->tenant?->organization_name,
|
||||
action: 'rejected',
|
||||
tariffName: null,
|
||||
comment: $comment,
|
||||
));
|
||||
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<p>Здравствуйте, {{ $managerName }}!</p>
|
||||
|
||||
@if($action === 'approved')
|
||||
<p>Ваша заявка на привязку клиента <strong>одобрена</strong>.</p>
|
||||
@if($tenantName)
|
||||
<p><strong>Клиент:</strong> {{ $tenantName }} ({{ $loginInput }})</p>
|
||||
@else
|
||||
<p><strong>Клиент:</strong> {{ $loginInput }}</p>
|
||||
@endif
|
||||
@if($tariffName)
|
||||
<p><strong>Назначенный тариф:</strong> {{ $tariffName }}</p>
|
||||
@else
|
||||
<p><em>Тариф не назначен — доход будет рассчитан после его установки.</em></p>
|
||||
@endif
|
||||
<p>Клиент закреплён за вами. Вы можете начать работу с ним в портале отдела продаж.</p>
|
||||
@else
|
||||
<p>Ваша заявка на привязку клиента <strong>отклонена</strong>.</p>
|
||||
@if($tenantName)
|
||||
<p><strong>Клиент:</strong> {{ $tenantName }} ({{ $loginInput }})</p>
|
||||
@else
|
||||
<p><strong>Клиент:</strong> {{ $loginInput }}</p>
|
||||
@endif
|
||||
@if($comment)
|
||||
<p><strong>Причина:</strong> {{ $comment }}</p>
|
||||
@endif
|
||||
<p>Если у вас есть вопросы, обратитесь к руководителю отдела продаж.</p>
|
||||
@endif
|
||||
@@ -0,0 +1,251 @@
|
||||
<?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 = makeTariff(['kind' => 'percent_oborot', 'params' => ['pct' => 10]]);
|
||||
$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' => 'fix_per_client', 'params' => ['fix_rub' => 2000]]);
|
||||
|
||||
$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('fix_per_client')
|
||||
->and($newAssignment->tariff_params)->toBe(['fix_rub' => 2000]);
|
||||
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user