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>
@
226 lines
7.9 KiB
PHP
226 lines
7.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
|
||
use App\Models\SalesClientAssignment;
|
||
use App\Models\SalesUser;
|
||
use App\Models\Tenant;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Hash;
|
||
|
||
/**
|
||
* TDD: трейт ScopesSalesOwnership.
|
||
*
|
||
* Проверяет логику ограничения ownership:
|
||
* - Менеджер видит только своих клиентов (tenant_ids из sales_client_assignments).
|
||
* - Начальник (role=head) на личных экранах видит ТОЛЬКО свои привязки;
|
||
* весь отдел — через visibleTenantIds + ?scope=department (§23).
|
||
*
|
||
* Изоляция: DatabaseTransactions — каждый тест откатывается.
|
||
* Используем DEFAULT connection (pgsql → liderra_testing).
|
||
*
|
||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
/**
|
||
* Зонд для тестирования трейта без реального контроллера.
|
||
*/
|
||
class OwnershipProbe
|
||
{
|
||
use ScopesSalesOwnership;
|
||
|
||
public function __construct(private SalesUser $u) {}
|
||
|
||
/** @return list<int> */
|
||
public function ids(): array
|
||
{
|
||
return $this->ownedTenantIds($this->u);
|
||
}
|
||
|
||
/** @return list<int>|null */
|
||
public function visible(Request $request): ?array
|
||
{
|
||
return $this->visibleTenantIds($this->u, $request);
|
||
}
|
||
|
||
public function scoped(Builder $query): Builder
|
||
{
|
||
return $this->scopeByOwnership($query, $this->u);
|
||
}
|
||
}
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function makeSalesUserOwn(string $role = 'manager'): SalesUser
|
||
{
|
||
return SalesUser::create([
|
||
'name' => ucfirst($role).' '.uniqid(),
|
||
'email' => $role.uniqid().'@scope.local',
|
||
'password' => Hash::make('secret'),
|
||
'role' => $role,
|
||
]);
|
||
}
|
||
|
||
// ── 1. ownedTenantIds ──────────────────────────────────────────────────────
|
||
|
||
test('ownedTenantIds для менеджера возвращает список его tenant_id', function () {
|
||
$manager = makeSalesUserOwn('manager');
|
||
|
||
$t1 = Tenant::factory()->create();
|
||
$t2 = Tenant::factory()->create();
|
||
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $t1->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $t2->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
|
||
$probe = new OwnershipProbe($manager);
|
||
$ids = $probe->ids();
|
||
|
||
expect($ids)->toBeArray()
|
||
->toEqualCanonicalizing([$t1->id, $t2->id]);
|
||
});
|
||
|
||
test('ownedTenantIds для head возвращает ЕГО ЛИЧНЫЕ привязки, а не весь отдел', function () {
|
||
$head = makeSalesUserOwn('head');
|
||
$manager = makeSalesUserOwn('manager');
|
||
|
||
$mine = Tenant::factory()->create();
|
||
$his = Tenant::factory()->create();
|
||
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $head->id, 'tenant_id' => $mine->id,
|
||
'tariff_params' => [], 'assigned_at' => now(),
|
||
]);
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id, 'tenant_id' => $his->id,
|
||
'tariff_params' => [], 'assigned_at' => now(),
|
||
]);
|
||
|
||
$ids = (new OwnershipProbe($head))->ids();
|
||
|
||
expect($ids)->toContain($mine->id)->not->toContain($his->id);
|
||
});
|
||
|
||
test('visibleTenantIds: весь отдел только начальнику и только по scope=department', function () {
|
||
$head = makeSalesUserOwn('head');
|
||
$manager = makeSalesUserOwn('manager');
|
||
|
||
$probe = fn (SalesUser $u, string $qs) => (new OwnershipProbe($u))
|
||
->visible(Request::create('/api/sales/clients'.$qs));
|
||
|
||
// Начальник без scope — только личное (у него привязок нет → пусто).
|
||
expect($probe($head, ''))->toBe([]);
|
||
// Начальник с scope=department — без ограничения.
|
||
expect($probe($head, '?scope=department'))->toBeNull();
|
||
// Менеджеру scope не помогает.
|
||
expect($probe($manager, '?scope=department'))->toBe([]);
|
||
});
|
||
|
||
test('ownedTenantIds для менеджера без клиентов возвращает пустой массив', function () {
|
||
$manager = makeSalesUserOwn('manager');
|
||
|
||
$probe = new OwnershipProbe($manager);
|
||
$ids = $probe->ids();
|
||
|
||
expect($ids)->toBeArray()->toBeEmpty();
|
||
});
|
||
|
||
// ── 2. scopeByOwnership ────────────────────────────────────────────────────
|
||
|
||
test('scopeByOwnership для менеджера ограничивает запрос его tenant_id', function () {
|
||
$manager = makeSalesUserOwn('manager');
|
||
$other = makeSalesUserOwn('manager');
|
||
|
||
$t1 = Tenant::factory()->create();
|
||
$t2 = Tenant::factory()->create();
|
||
$t3 = Tenant::factory()->create();
|
||
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $t1->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $t2->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
// t3 принадлежит другому менеджеру
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $other->id,
|
||
'tenant_id' => $t3->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
|
||
$probe = new OwnershipProbe($manager);
|
||
$results = $probe->scoped(SalesClientAssignment::query())->get();
|
||
|
||
$tenantIds = $results->pluck('tenant_id')->sort()->values()->all();
|
||
|
||
expect($tenantIds)->toEqualCanonicalizing([$t1->id, $t2->id]);
|
||
});
|
||
|
||
test('scopeByOwnership ограничивает и начальника — только его привязки', function () {
|
||
$head = makeSalesUserOwn('head');
|
||
$manager = makeSalesUserOwn('manager');
|
||
|
||
$t1 = Tenant::factory()->create();
|
||
$t2 = Tenant::factory()->create();
|
||
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $t1->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $manager->id,
|
||
'tenant_id' => $t2->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
|
||
$probe = new OwnershipProbe($head);
|
||
$results = $probe->scoped(SalesClientAssignment::query())->get();
|
||
|
||
// У начальника своих привязок нет → чужие записи менеджера не видны.
|
||
expect($results->pluck('tenant_id')->all())
|
||
->not->toContain($t1->id)
|
||
->not->toContain($t2->id);
|
||
});
|
||
|
||
test('scopeByOwnership для менеджера без клиентов возвращает пустую коллекцию', function () {
|
||
$manager = makeSalesUserOwn('manager');
|
||
|
||
// Создаём другого менеджера с клиентом, чтобы таблица не была пустой
|
||
$other = makeSalesUserOwn('manager');
|
||
$t1 = Tenant::factory()->create();
|
||
SalesClientAssignment::create([
|
||
'sales_user_id' => $other->id,
|
||
'tenant_id' => $t1->id,
|
||
'tariff_params' => [],
|
||
'assigned_at' => now(),
|
||
]);
|
||
|
||
$probe = new OwnershipProbe($manager);
|
||
$results = $probe->scoped(SalesClientAssignment::query())->get();
|
||
|
||
expect($results)->toBeEmpty();
|
||
});
|