277800149f
fix(sales): строгое разделение двух ролей начальника — экраны «мои» больше не показывают отдел
Начальник продаёт сам и руководит отделом. Правило «начальник видит всё»
применялось к ЧЕЛОВЕКУ, а не к экрану, поэтому разделы меню врали:
- «Потенциальные клиенты» показывали все карточки отдела — точную копию
«Воронки отдела» (это владелец и заметил);
- «Мои клиенты» и «Сводка» — всех клиентов отдела;
- «Привязать клиента» — очередь заявок отдела, причём в ЧУЖОМ формате
({pending, history} вместо {data}), форма получала не те данные.
Новое правило: роли разделяются по ЭКРАНУ. Любой запрос по умолчанию отдаёт
только личное — включая начальника. Весь отдел выдаётся только по явному
?scope=department, и просят его только экраны раздела НАЧАЛЬНИК. Менеджеру
параметр ничего не даёт: проверка по роли, не по параметру.
ownedTenantIds теперь ВСЕГДА личные привязки (тип сузился с ?array до array),
добавлен visibleTenantIds для области видимости запроса.
Правом начальника осталось открыть ЛЮБУЮ карточку — кандидата и клиента:
иначе из «Воронки отдела» не открылась бы карточка чужого менеджера.
Ограничены только списки, не доступ к записи.
Следствие: у начальника сейчас 0 своих кандидатов и клиентов, поэтому его
личные экраны станут пустыми — это правильно, а не поломка.
Pest 272/272, Vitest зелёный, vue-tsc чист, Larastan без своих ошибок.
Спека — §23.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\SalesAttachmentRequest;
|
|
use App\Models\SalesClientAssignment;
|
|
use App\Models\SalesUser;
|
|
use App\Models\Tenant;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Tests\Concerns\SharesSupplierPdo;
|
|
|
|
/**
|
|
* TDD: SalesAttachmentController — HTTP endpoints для заявок на привязку клиентов.
|
|
*
|
|
* Покрывает Task 2.3:
|
|
* - POST /api/sales/attachments → менеджер подаёт заявку
|
|
* - GET /api/sales/attachments → список (менеджер/начальник)
|
|
* - POST /api/sales/attachments/{id}/decide → решение начальника
|
|
*
|
|
* Аутентификация: Sanctum::actingAs($user, [], 'sales').
|
|
* Изоляция: DatabaseTransactions + SharesSupplierPdo (admin-db middleware).
|
|
* DB_DATABASE=liderra_testing.
|
|
*/
|
|
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function apiManager(array $attrs = []): SalesUser
|
|
{
|
|
return SalesUser::create(array_merge([
|
|
'name' => 'Менеджер-API '.uniqid(),
|
|
'email' => 'apimgr'.uniqid().'@sales.local',
|
|
'password' => bcrypt('secret'),
|
|
'role' => 'manager',
|
|
'is_active' => true,
|
|
], $attrs));
|
|
}
|
|
|
|
function apiHead(array $attrs = []): SalesUser
|
|
{
|
|
return SalesUser::create(array_merge([
|
|
'name' => 'Начальник-API '.uniqid(),
|
|
'email' => 'apihead'.uniqid().'@sales.local',
|
|
'password' => bcrypt('secret'),
|
|
'role' => 'head',
|
|
'is_active' => true,
|
|
], $attrs));
|
|
}
|
|
|
|
function apiTenant(string $email = ''): Tenant
|
|
{
|
|
return Tenant::factory()->create([
|
|
'contact_email' => $email !== '' ? $email : 'apiclient'.uniqid().'@example.com',
|
|
]);
|
|
}
|
|
|
|
// ── store: POST /api/sales/attachments ───────────────────────────────────────
|
|
|
|
test('manager POST /api/sales/attachments with existing client email → 201 pending', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$tenant = apiTenant('known-client@example.com');
|
|
|
|
$response = $this->actingAs($manager, 'sales')
|
|
->postJson('/api/sales/attachments', ['login' => 'known-client@example.com']);
|
|
|
|
$response->assertCreated();
|
|
expect($response->json('status'))->toBe('pending');
|
|
expect($response->json('tenant_id'))->toBe($tenant->id);
|
|
expect($response->json('status_label'))->toBe('На рассмотрении');
|
|
});
|
|
|
|
test('manager POST with unknown login → 201 not_found', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
|
|
$response = $this->actingAs($manager, 'sales')
|
|
->postJson('/api/sales/attachments', ['login' => 'nobody-xyz@unknown.test']);
|
|
|
|
$response->assertCreated();
|
|
expect($response->json('status'))->toBe('not_found');
|
|
expect($response->json('tenant_id'))->toBeNull();
|
|
expect($response->json('status_label'))->toBe('Клиент не найден');
|
|
});
|
|
|
|
test('manager POST for client already assigned to them → 422 with plain-Russian message', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$tenant = apiTenant('own-api-client@example.com');
|
|
|
|
SalesClientAssignment::create([
|
|
'sales_user_id' => $manager->id,
|
|
'tenant_id' => $tenant->id,
|
|
'tariff_params' => [],
|
|
'assigned_at' => now(),
|
|
]);
|
|
|
|
$response = $this->actingAs($manager, 'sales')
|
|
->postJson('/api/sales/attachments', ['login' => 'own-api-client@example.com']);
|
|
|
|
$response->assertStatus(422);
|
|
expect($response->json('message'))->toBe('Этот клиент уже ваш.');
|
|
});
|
|
|
|
test('POST without login field → 422 validation error', function () {
|
|
$manager = apiManager();
|
|
|
|
$this->actingAs($manager, 'sales')
|
|
->postJson('/api/sales/attachments', [])
|
|
->assertStatus(422);
|
|
});
|
|
|
|
// ── index: GET /api/sales/attachments ────────────────────────────────────────
|
|
|
|
test('manager GET /api/sales/attachments sees only their own requests', function () {
|
|
Mail::fake();
|
|
|
|
$managerA = apiManager();
|
|
$managerB = apiManager();
|
|
|
|
// Создаём заявки для обоих менеджеров
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $managerA->id,
|
|
'login_input' => 'client-a@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $managerB->id,
|
|
'login_input' => 'client-b@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$response = $this->actingAs($managerA, 'sales')
|
|
->getJson('/api/sales/attachments');
|
|
|
|
$response->assertOk();
|
|
$data = $response->json('data');
|
|
expect($data)->not->toBeNull()->toHaveCount(1);
|
|
expect($data[0]['login_input'])->toBe('client-a@example.com');
|
|
});
|
|
|
|
test('head GET /api/sales/attachments sees pending queue including other managers requests', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$head = apiHead();
|
|
|
|
// Pending заявка от менеджера
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $manager->id,
|
|
'login_input' => 'head-visible@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$response = $this->actingAs($head, 'sales')
|
|
->getJson('/api/sales/attachments?scope=department');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('pending'))->not->toBeNull();
|
|
expect($response->json('history'))->not->toBeNull();
|
|
|
|
$pending = $response->json('pending');
|
|
$logins = array_column($pending, 'login_input');
|
|
expect($logins)->toContain('head-visible@example.com');
|
|
});
|
|
|
|
test('head GET index returns manager_name in pending rows', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager(['name' => 'Иван Тестов '.uniqid()]);
|
|
$head = apiHead();
|
|
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $manager->id,
|
|
'login_input' => 'head-mgr-name@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$response = $this->actingAs($head, 'sales')
|
|
->getJson('/api/sales/attachments?scope=department');
|
|
|
|
$response->assertOk();
|
|
$pending = $response->json('pending');
|
|
expect($pending)->not->toBeEmpty();
|
|
|
|
// Ищем нашу заявку
|
|
$found = collect($pending)->firstWhere('login_input', 'head-mgr-name@example.com');
|
|
expect($found)->not->toBeNull();
|
|
expect($found['manager_name'])->toContain('Иван Тестов');
|
|
});
|
|
|
|
// ── decide: POST /api/sales/attachments/{id}/decide ──────────────────────────
|
|
|
|
test('head POST decide approve → 200, assignment created', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$head = apiHead();
|
|
$tenant = apiTenant('decide-test@example.com');
|
|
|
|
$req = SalesAttachmentRequest::create([
|
|
'sales_user_id' => $manager->id,
|
|
'login_input' => 'decide-test@example.com',
|
|
'tenant_id' => $tenant->id,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$response = $this->actingAs($head, 'sales')
|
|
->postJson("/api/sales/attachments/{$req->id}/decide", ['action' => 'approve']);
|
|
|
|
$response->assertOk();
|
|
expect($response->json('status'))->toBe('approved');
|
|
expect($response->json('status_label'))->toBe('Одобрено');
|
|
|
|
// Spot-check: assignment создан
|
|
$assignment = SalesClientAssignment::where('tenant_id', $tenant->id)->first();
|
|
expect($assignment)->not->toBeNull();
|
|
expect($assignment->sales_user_id)->toBe($manager->id);
|
|
});
|
|
|
|
test('manager POST decide → 403 plain Russian message', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$otherManager = apiManager();
|
|
$tenant = apiTenant('mgr-decide@example.com');
|
|
|
|
$req = SalesAttachmentRequest::create([
|
|
'sales_user_id' => $otherManager->id,
|
|
'login_input' => 'mgr-decide@example.com',
|
|
'tenant_id' => $tenant->id,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$response = $this->actingAs($manager, 'sales')
|
|
->postJson("/api/sales/attachments/{$req->id}/decide", ['action' => 'approve']);
|
|
|
|
$response->assertStatus(403);
|
|
expect($response->json('message'))->toBe('Только начальник может решать по заявкам.');
|
|
});
|
|
|
|
test('decide with invalid action → 422 validation error', function () {
|
|
$head = apiHead();
|
|
$manager = apiManager();
|
|
$tenant = apiTenant('bad-action@example.com');
|
|
|
|
$req = SalesAttachmentRequest::create([
|
|
'sales_user_id' => $manager->id,
|
|
'login_input' => 'bad-action@example.com',
|
|
'tenant_id' => $tenant->id,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$this->actingAs($head, 'sales')
|
|
->postJson("/api/sales/attachments/{$req->id}/decide", ['action' => 'invalidaction'])
|
|
->assertStatus(422);
|
|
});
|
|
|
|
// ── unauthenticated ───────────────────────────────────────────────────────────
|
|
|
|
test('unauthenticated POST /api/sales/attachments → 401', function () {
|
|
$this->postJson('/api/sales/attachments', ['login' => 'test@example.com'])
|
|
->assertUnauthorized();
|
|
});
|
|
|
|
test('unauthenticated GET /api/sales/attachments → 401', function () {
|
|
$this->getJson('/api/sales/attachments')
|
|
->assertUnauthorized();
|
|
});
|
|
|
|
// ── строгое разделение ролей начальника (18.07.2026) ──────────────────────────
|
|
|
|
test('на личном экране начальника приходят СВОИ заявки, а не очередь отдела', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$head = apiHead();
|
|
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $manager->id,
|
|
'login_input' => 'chuzhaya@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $head->id,
|
|
'login_input' => 'moya@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
// Без scope — форма «Привязать клиента» (раздел МЕНЕДЖЕР): формат менеджера.
|
|
$mine = $this->actingAs($head, 'sales')->getJson('/api/sales/attachments')->assertOk();
|
|
expect($mine->json('pending'))->toBeNull();
|
|
$logins = array_column($mine->json('data'), 'login_input');
|
|
expect($logins)->toContain('moya@example.com')->not->toContain('chuzhaya@example.com');
|
|
|
|
// scope=department — экран «Заявки на привязку» (раздел НАЧАЛЬНИК).
|
|
$queue = $this->actingAs($head, 'sales')
|
|
->getJson('/api/sales/attachments?scope=department')->assertOk();
|
|
expect(array_column($queue->json('pending'), 'login_input'))
|
|
->toContain('chuzhaya@example.com');
|
|
});
|
|
|
|
test('менеджер не получает очередь отдела через ?scope=department', function () {
|
|
Mail::fake();
|
|
|
|
$manager = apiManager();
|
|
$other = apiManager();
|
|
|
|
SalesAttachmentRequest::create([
|
|
'sales_user_id' => $other->id,
|
|
'login_input' => 'ne-moya@example.com',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$res = $this->actingAs($manager, 'sales')
|
|
->getJson('/api/sales/attachments?scope=department')->assertOk();
|
|
|
|
expect($res->json('pending'))->toBeNull();
|
|
expect(array_column($res->json('data'), 'login_input'))->not->toContain('ne-moya@example.com');
|
|
});
|