cedbb9de92
Task 1.4a: GET /api/sales/clients/{tenantId} — профиль, KPI (баланс/запас/проекты/лиды-цель/средняя цена лида, earned=null до Фазы 3), проекты, лиды по дням, последние лиды с МАСКИРОВАННЫМ телефоном, активность. 403 для чужого клиента (ScopesSalesOwnership), начальник видит всех. Тест 8/8, весь sales 65/65, stan 0. Один эскейп на сессию.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
11 KiB
PHP
299 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\SalesClientAssignment;
|
|
use App\Models\SalesUser;
|
|
use App\Models\Tenant;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Testing\TestResponse;
|
|
|
|
/**
|
|
* TDD: GET /api/sales/clients/{tenantId} — карточка клиента.
|
|
*
|
|
* Покрывает Task 1.4 портала продаж:
|
|
* - Менеджер открывает только СВОЕГО клиента (200) — иначе 403.
|
|
* - Начальник открывает любого клиента (200).
|
|
* - Ответ содержит: profile, kpi, projects, leads_by_day, recent_leads, activity.
|
|
* - Телефоны лидов в recent_leads — МАСКИРОВАНЫ.
|
|
* - earned_rub всегда null (Phase 3).
|
|
*
|
|
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
|
|
* SharesAdminPdo применяется глобально в Pest.php — admin-db middleware
|
|
* переключает default→pgsql_admin, sharing PDO обеспечивает видимость засеянных данных.
|
|
*
|
|
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.4)
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function card_makeSalesUser(string $role = 'manager'): SalesUser
|
|
{
|
|
return SalesUser::create([
|
|
'name' => ucfirst($role).' '.uniqid(),
|
|
'email' => 'card-test-'.$role.uniqid().'@test.local',
|
|
'password' => Hash::make('secret'),
|
|
'role' => $role,
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
|
|
function card_getClientCard(SalesUser $user, int $tenantId, array $params = []): TestResponse
|
|
{
|
|
$token = $user->createToken('sales')->plainTextToken;
|
|
$url = '/api/sales/clients/'.$tenantId;
|
|
if ($params !== []) {
|
|
$url .= '?'.http_build_query($params);
|
|
}
|
|
|
|
return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url);
|
|
}
|
|
|
|
/**
|
|
* Создаёт тенанта с реквизитами.
|
|
*/
|
|
function card_makeTenant(string $orgName = 'Тест-Орг', float $balance = 1000.0): Tenant
|
|
{
|
|
$tenant = Tenant::factory()->create([
|
|
'organization_name' => $orgName,
|
|
'status' => 'active',
|
|
'is_trial' => false,
|
|
'balance_rub' => (string) $balance,
|
|
'chargeback_unrecovered_rub' => '0.00',
|
|
'desired_daily_numbers' => 5,
|
|
'contact_email' => 'contact@test.local',
|
|
'last_activity_at' => now(),
|
|
]);
|
|
|
|
DB::table('tenant_requisites')->insert([
|
|
'tenant_id' => $tenant->id,
|
|
'subject_type' => 'legal_entity',
|
|
'contact_name' => 'Иванов Иван Иванович',
|
|
'contact_phone' => '+70001112233',
|
|
'inn' => '1234567890',
|
|
'legal_address' => 'г. Москва, ул. Тестовая, 1',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
/**
|
|
* Присваивает тенанта менеджеру.
|
|
*/
|
|
function card_assignTenant(SalesUser $manager, Tenant $tenant): SalesClientAssignment
|
|
{
|
|
return SalesClientAssignment::create([
|
|
'sales_user_id' => $manager->id,
|
|
'tenant_id' => $tenant->id,
|
|
'tariff_id' => null,
|
|
'tariff_kind' => null,
|
|
'tariff_params' => [],
|
|
'assigned_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Создаёт проект для тенанта и возвращает его id.
|
|
*/
|
|
function card_makeProject(int $tenantId): int
|
|
{
|
|
// signal_type=NULL пропускает constraint chk_projects_signal_identifier_required
|
|
// (constraint требует signal_identifier только для site/call)
|
|
return (int) DB::table('projects')->insertGetId([
|
|
'tenant_id' => $tenantId,
|
|
'name' => 'Тест-Проект '.uniqid(),
|
|
'tag' => 'tag-'.uniqid(),
|
|
'is_active' => true,
|
|
'signal_type' => null,
|
|
'daily_limit_target' => 10,
|
|
'delivery_days_mask' => 127,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Вставляет сделку с конкретным телефоном и возвращает её deal_id.
|
|
*/
|
|
function card_insertDeal(int $tenantId, int $projectId, string $phone, string $receivedAt): int
|
|
{
|
|
return (int) DB::table('deals')->insertGetId([
|
|
'tenant_id' => $tenantId,
|
|
'project_id' => $projectId,
|
|
'source_crm_id' => rand(100_000_000, 999_999_999),
|
|
'phone' => $phone,
|
|
'status' => 'new',
|
|
'is_test' => false,
|
|
'received_at' => $receivedAt,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Вставляет запись lead_charges для тенанта.
|
|
*
|
|
* @param int $dealId — id сделки (NOT NULL в схеме)
|
|
* @param string $dealReceivedAt — received_at сделки (NOT NULL, composite FK)
|
|
*/
|
|
function card_insertCharge(int $tenantId, int $priceKopecks, string $chargedAt, int $dealId, string $dealReceivedAt): void
|
|
{
|
|
DB::table('lead_charges')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'deal_id' => $dealId,
|
|
'deal_received_at' => $dealReceivedAt,
|
|
'tier_no' => 1,
|
|
'price_per_lead_kopecks' => $priceKopecks,
|
|
'charge_source' => 'rub',
|
|
'charged_at' => $chargedAt,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Вставляет баланс-транзакцию.
|
|
*/
|
|
function card_insertTransaction(int $tenantId, string $type, float $amountRub, string $description = ''): void
|
|
{
|
|
DB::table('balance_transactions')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'type' => $type,
|
|
'amount_rub' => $amountRub,
|
|
'description' => $description,
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
// ── тесты ────────────────────────────────────────────────────────────────────
|
|
|
|
test('менеджер открывает своего клиента → 200, profile и kpi присутствуют', function () {
|
|
$manager = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('ООО Тест-Клиент', 5000.0);
|
|
card_assignTenant($manager, $tenant);
|
|
|
|
$response = card_getClientCard($manager, $tenant->id);
|
|
|
|
$response->assertOk();
|
|
|
|
expect($response->json('profile.organization_name'))->toBe('ООО Тест-Клиент');
|
|
expect($response->json('kpi.balance_rub'))->not->toBeNull();
|
|
expect($response->json('kpi.earned_rub'))->toBeNull();
|
|
});
|
|
|
|
test('менеджер открывает чужого клиента → 403', function () {
|
|
$manager = card_makeSalesUser('manager');
|
|
$manager2 = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('Чужой Клиент');
|
|
card_assignTenant($manager2, $tenant); // назначен другому менеджеру
|
|
|
|
$response = card_getClientCard($manager, $tenant->id);
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('начальник открывает любого клиента → 200', function () {
|
|
$head = card_makeSalesUser('head');
|
|
$manager = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('Клиент Начальника');
|
|
card_assignTenant($manager, $tenant);
|
|
|
|
$response = card_getClientCard($head, $tenant->id);
|
|
|
|
$response->assertOk();
|
|
expect($response->json('profile.organization_name'))->toBe('Клиент Начальника');
|
|
});
|
|
|
|
test('recent_leads — телефоны замаскированы', function () {
|
|
$manager = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('Клиент Маска');
|
|
card_assignTenant($manager, $tenant);
|
|
|
|
$projectId = card_makeProject($tenant->id);
|
|
$rawPhone = '79161234567';
|
|
$now = CarbonImmutable::now('Europe/Moscow')->format('Y-m-d H:i:s');
|
|
card_insertDeal($tenant->id, $projectId, $rawPhone, $now);
|
|
|
|
$response = card_getClientCard($manager, $tenant->id);
|
|
$response->assertOk();
|
|
|
|
$leads = $response->json('recent_leads');
|
|
expect($leads)->toBeArray()->not->toBeEmpty();
|
|
|
|
// Маска: «79** *** ** 67» (первые 2 цифры + ** *** ** + 2 последних)
|
|
// Сырой телефон НЕ должен быть виден
|
|
$phone = $leads[0]['phone_masked'];
|
|
expect($phone)->not->toBe($rawPhone);
|
|
expect($phone)->toContain('**');
|
|
});
|
|
|
|
test('kpi содержит leads_delivered, avg_lead_price_rub, earned_rub=null', function () {
|
|
$manager = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('Клиент KPI', 2000.0);
|
|
card_assignTenant($manager, $tenant);
|
|
|
|
$projectId = card_makeProject($tenant->id);
|
|
$now = CarbonImmutable::now('Europe/Moscow');
|
|
$inPeriod = $now->format('Y-m-d H:i:s');
|
|
|
|
$dealId = card_insertDeal($tenant->id, $projectId, '79001112233', $inPeriod);
|
|
card_insertCharge($tenant->id, 2500, $inPeriod, $dealId, $inPeriod); // 25.00 руб за лид
|
|
|
|
$response = card_getClientCard($manager, $tenant->id);
|
|
$response->assertOk();
|
|
|
|
expect($response->json('kpi.leads_delivered'))->toBe(1);
|
|
// avg_lead_price_rub = 2500 kopecks / 100 / 1 лид = 25 руб
|
|
expect((float) $response->json('kpi.avg_lead_price_rub'))->toBe(25.0);
|
|
expect($response->json('kpi.earned_rub'))->toBeNull();
|
|
});
|
|
|
|
test('projects содержит проект тенанта', function () {
|
|
$manager = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('Клиент Проекты');
|
|
card_assignTenant($manager, $tenant);
|
|
|
|
card_makeProject($tenant->id);
|
|
|
|
$response = card_getClientCard($manager, $tenant->id);
|
|
$response->assertOk();
|
|
|
|
$projects = $response->json('projects');
|
|
expect($projects)->toBeArray()->not->toBeEmpty();
|
|
expect($projects[0])->toHaveKey('id');
|
|
expect($projects[0])->toHaveKey('name');
|
|
expect($projects[0])->toHaveKey('signal_type');
|
|
expect($projects[0])->toHaveKey('daily_limit_target');
|
|
expect($projects[0])->toHaveKey('delivered_today');
|
|
expect($projects[0])->toHaveKey('status');
|
|
});
|
|
|
|
test('activity содержит balance_transactions', function () {
|
|
$manager = card_makeSalesUser('manager');
|
|
$tenant = card_makeTenant('Клиент Актив', 1500.0);
|
|
card_assignTenant($manager, $tenant);
|
|
|
|
card_insertTransaction($tenant->id, 'topup', 500.0, 'Тестовое пополнение');
|
|
|
|
$response = card_getClientCard($manager, $tenant->id);
|
|
$response->assertOk();
|
|
|
|
$activity = $response->json('activity');
|
|
expect($activity)->toBeArray()->not->toBeEmpty();
|
|
|
|
$tx = $activity[0];
|
|
expect($tx)->toHaveKey('type');
|
|
expect($tx)->toHaveKey('amount_rub');
|
|
expect($tx)->toHaveKey('description');
|
|
expect($tx)->toHaveKey('created_at');
|
|
});
|
|
|
|
test('запрос без токена → 401', function () {
|
|
test()->getJson('/api/sales/clients/1')
|
|
->assertUnauthorized();
|
|
});
|