Files
portal/app/tests/Feature/Sales/SalesClientCardTest.php
T
Дмитрий 3b8694cd4c feat(sales): живой доход по клиентам в списке/карточке/сводке (бэк)
Task 3.3a — SalesClientsController наполняет earned_rub через
SalesEarningsService (был null-заглушкой):
- index: доход по каждому клиенту за период по снимку тарифа привязки;
  клиент без привязки (начальник видит незакреплённых) → null.
- show (kpi): доход по одному клиенту.
- overview (totals): сумма комиссии по всем привязкам в scope (без оклада).

Тесты Фазы 1 обновлены под реальные значения (percent_oborot 10/20% от
засеянного оборота → 500/5/10000 ₽; привязка без тарифа → 0.0; без
привязки → null). Попутно исправлен ключ params тест-тарифа
(percent→rate, движок читает rate). Sales-набор 132/132, Larastan 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:32:00 +03:00

310 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
// Привязка без тарифа (tariff_kind=null) → доход 0.0.
expect((float) $response->json('kpi.earned_rub'))->toBe(0.0);
});
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 по тарифу', function () {
$manager = card_makeSalesUser('manager');
$tenant = card_makeTenant('Клиент KPI', 2000.0);
// Привязка со снимком тарифа percent_oborot 20% → доход = оборот × 20%.
SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => null,
'tariff_kind' => 'percent_oborot',
'tariff_params' => ['rate' => 20],
'assigned_at' => now(),
]);
$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);
// earned_rub = оборот 25 ₽ × 20% = 5.0
expect((float) $response->json('kpi.earned_rub'))->toBe(5.0);
});
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();
});