diff --git a/app/app/Mail/Sales/AttachmentDecidedMail.php b/app/app/Mail/Sales/AttachmentDecidedMail.php new file mode 100644 index 00000000..576bb1b7 --- /dev/null +++ b/app/app/Mail/Sales/AttachmentDecidedMail.php @@ -0,0 +1,62 @@ +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, + ], + ); + } +} diff --git a/app/app/Services/Sales/SalesAttachmentService.php b/app/app/Services/Sales/SalesAttachmentService.php index de078c0b..160ebe49 100644 --- a/app/app/Services/Sales/SalesAttachmentService.php +++ b/app/app/Services/Sales/SalesAttachmentService.php @@ -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; + } } diff --git a/app/resources/views/mail/sales/attachment-decided.blade.php b/app/resources/views/mail/sales/attachment-decided.blade.php new file mode 100644 index 00000000..2552e7d6 --- /dev/null +++ b/app/resources/views/mail/sales/attachment-decided.blade.php @@ -0,0 +1,27 @@ +
Здравствуйте, {{ $managerName }}!
+ +@if($action === 'approved') +Ваша заявка на привязку клиента одобрена.
+@if($tenantName) +Клиент: {{ $tenantName }} ({{ $loginInput }})
+@else +Клиент: {{ $loginInput }}
+@endif +@if($tariffName) +Назначенный тариф: {{ $tariffName }}
+@else +Тариф не назначен — доход будет рассчитан после его установки.
+@endif +Клиент закреплён за вами. Вы можете начать работу с ним в портале отдела продаж.
+@else +Ваша заявка на привязку клиента отклонена.
+@if($tenantName) +Клиент: {{ $tenantName }} ({{ $loginInput }})
+@else +Клиент: {{ $loginInput }}
+@endif +@if($comment) +Причина: {{ $comment }}
+@endif +Если у вас есть вопросы, обратитесь к руководителю отдела продаж.
+@endif diff --git a/app/tests/Feature/Sales/SalesAttachmentDecideTest.php b/app/tests/Feature/Sales/SalesAttachmentDecideTest.php new file mode 100644 index 00000000..f8ea745a --- /dev/null +++ b/app/tests/Feature/Sales/SalesAttachmentDecideTest.php @@ -0,0 +1,251 @@ + 'Менеджер '.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); +});