feat(sales): карточка клиента (бэкенд)
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>
This commit is contained in:
@@ -15,22 +15,20 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Портал продаж — экран «Мои клиенты».
|
||||
* Портал продаж — экран «Мои клиенты» + карточка клиента.
|
||||
*
|
||||
* GET /api/sales/clients
|
||||
* GET /api/sales/clients — список (Task 1.3)
|
||||
* GET /api/sales/clients/{tenantId} — карточка (Task 1.4)
|
||||
*
|
||||
* Менеджер видит только своих клиентов (через ScopesSalesOwnership);
|
||||
* начальник (role=head) видит всех.
|
||||
*
|
||||
* Параметры:
|
||||
* Параметры периода (оба метода):
|
||||
* ?period=this|prev|prev2|custom (default: this)
|
||||
* ?from=YYYY-MM-DD (только для period=custom)
|
||||
* ?to=YYYY-MM-DD (только для period=custom)
|
||||
* ?search=... (ilike по organization_name / inn)
|
||||
*
|
||||
* TODO: пагинация — добавить в следующей фазе.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3)
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3, Task 1.4)
|
||||
*/
|
||||
class SalesClientsController extends Controller
|
||||
{
|
||||
@@ -135,4 +133,246 @@ class SalesClientsController extends Controller
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка клиента.
|
||||
*
|
||||
* GET /api/sales/clients/{tenantId}
|
||||
*
|
||||
* Менеджер может открыть только своего клиента (иначе 403).
|
||||
* Начальник открывает любого.
|
||||
*
|
||||
* Ответ:
|
||||
* profile — анкетные данные тенанта + реквизиты
|
||||
* kpi — текущий баланс, runway, счётчики за период
|
||||
* projects — список проектов тенанта
|
||||
* leads_by_day — лиды по дням (last ~14 дней или в рамках периода)
|
||||
* recent_leads — последние ~20 лидов (телефоны МАСКИРОВАНЫ)
|
||||
* activity — последние ~10 balance_transactions
|
||||
*/
|
||||
public function show(Request $request, int $tenantId): JsonResponse
|
||||
{
|
||||
/** @var SalesUser $user */
|
||||
$user = $request->user('sales');
|
||||
|
||||
// 1. Проверка ownership: менеджер может смотреть только своих клиентов
|
||||
$ids = $this->ownedTenantIds($user);
|
||||
if ($ids !== null && ! in_array($tenantId, $ids, true)) {
|
||||
abort(403, 'Этот клиент не закреплён за вами.');
|
||||
}
|
||||
|
||||
// 2. Период для KPI-метрик
|
||||
$period = app(SalesPeriodResolver::class)->resolve([
|
||||
'kind' => $request->query('period', 'this'),
|
||||
'from' => $request->query('from'),
|
||||
'to' => $request->query('to'),
|
||||
]);
|
||||
|
||||
// 3. Основные данные тенанта + реквизиты
|
||||
$tenant = DB::table('tenants')
|
||||
->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id')
|
||||
->where('tenants.id', $tenantId)
|
||||
->whereNull('tenants.deleted_at')
|
||||
->select([
|
||||
'tenants.id',
|
||||
'tenants.organization_name',
|
||||
'tenants.contact_email',
|
||||
'tenants.desired_daily_numbers',
|
||||
'tenants.balance_rub',
|
||||
'tenants.last_activity_at',
|
||||
'tenants.created_at',
|
||||
'tenants.status',
|
||||
'tenants.is_trial',
|
||||
'tenants.chargeback_unrecovered_rub',
|
||||
'tenant_requisites.contact_name',
|
||||
'tenant_requisites.contact_phone',
|
||||
'tenant_requisites.inn',
|
||||
'tenant_requisites.subject_type',
|
||||
'tenant_requisites.legal_address',
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($tenant === null) {
|
||||
abort(404, 'Клиент не найден.');
|
||||
}
|
||||
|
||||
// 4. Метрики
|
||||
$metrics = app(SalesMetricsService::class);
|
||||
$leadsDelivered = $metrics->leadsDelivered($tenantId, $period);
|
||||
$oborotRub = $metrics->oborotRub($tenantId, $period);
|
||||
$runwayDays = $metrics->runwayDays($tenantId);
|
||||
|
||||
// projects_count: все проекты тенанта
|
||||
$projectsCount = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->count();
|
||||
|
||||
// leads_target: сумма daily_limit_target активных проектов × число дней в периоде
|
||||
$totalDailyTarget = (int) DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$daysInPeriod = (int) max(1, $period->start->diffInDays($period->end) + 1);
|
||||
$leadsTarget = $totalDailyTarget * $daysInPeriod;
|
||||
|
||||
$avgLeadPriceRub = $oborotRub / max(1, $leadsDelivered);
|
||||
|
||||
// 5. Проекты
|
||||
$projects = DB::table('projects')
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('id')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn (object $p): array => [
|
||||
'id' => (int) $p->id,
|
||||
'name' => $p->name,
|
||||
'signal_type' => $p->signal_type,
|
||||
'region' => $p->regions ?? [],
|
||||
'daily_limit_target' => (int) $p->daily_limit_target,
|
||||
'delivered_today' => (int) $p->delivered_today,
|
||||
'status' => (bool) $p->is_active ? 'active' : 'paused',
|
||||
])
|
||||
->all();
|
||||
|
||||
// 6. Лиды по дням (последние 14 дней)
|
||||
// Оборот за каждый день подтягиваем одним запросом из lead_charges,
|
||||
// сгруппированным по дню, и мержим с результатами deals.
|
||||
$last14Start = CarbonImmutable::now('Europe/Moscow')->subDays(13)->startOfDay();
|
||||
$last14End = CarbonImmutable::now('Europe/Moscow')->startOfDay()->addDay(); // завтра 00:00
|
||||
|
||||
$leadsByDayRows = DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false)
|
||||
->where('received_at', '>=', $last14Start)
|
||||
->select([
|
||||
DB::raw("DATE(received_at AT TIME ZONE 'Europe/Moscow') as day"),
|
||||
DB::raw('COUNT(*) as cnt'),
|
||||
])
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
// lead_charges за те же 14 дней, сгруппированные по дню (МСК)
|
||||
$chargesByDayRows = DB::table('lead_charges')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('charged_at', '>=', $last14Start)
|
||||
->where('charged_at', '<', $last14End)
|
||||
->select([
|
||||
DB::raw("DATE(charged_at AT TIME ZONE 'Europe/Moscow') as day"),
|
||||
DB::raw('SUM(price_per_lead_kopecks) as sum_kopecks'),
|
||||
])
|
||||
->groupBy('day')
|
||||
->get()
|
||||
->keyBy('day');
|
||||
|
||||
$leadsByDayFormatted = $leadsByDayRows->map(function (object $row) use ($chargesByDayRows): array {
|
||||
$dayStr = (string) $row->day;
|
||||
$sumKopecks = isset($chargesByDayRows[$dayStr])
|
||||
? (int) $chargesByDayRows[$dayStr]->sum_kopecks
|
||||
: 0;
|
||||
|
||||
return [
|
||||
'date' => $dayStr,
|
||||
'count' => (int) $row->cnt,
|
||||
'oborot_rub' => $sumKopecks / 100,
|
||||
];
|
||||
})->all();
|
||||
|
||||
// 7. Последние лиды (~20), телефоны маскированы
|
||||
$recentLeads = DB::table('deals')
|
||||
->leftJoin('projects', 'projects.id', '=', 'deals.project_id')
|
||||
->where('deals.tenant_id', $tenantId)
|
||||
->whereNull('deals.deleted_at')
|
||||
->where('deals.is_test', false)
|
||||
->orderByDesc('deals.received_at')
|
||||
->limit(20)
|
||||
->select([
|
||||
'deals.received_at',
|
||||
'deals.phone',
|
||||
'deals.region_code',
|
||||
'deals.city',
|
||||
'projects.name as project_name',
|
||||
'projects.signal_type',
|
||||
])
|
||||
->get()
|
||||
->map(fn (object $d): array => [
|
||||
'received_at' => CarbonImmutable::parse($d->received_at)->toIso8601String(),
|
||||
'phone_masked' => $this->maskPhone($d->phone),
|
||||
'region' => $d->city ?? $d->region_code,
|
||||
'source' => ($d->project_name ?? '—').($d->signal_type !== null ? ' / '.$d->signal_type : ''),
|
||||
'project' => $d->project_name,
|
||||
])
|
||||
->all();
|
||||
|
||||
// 8. Активность — последние 10 balance_transactions
|
||||
$activity = DB::table('balance_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->limit(10)
|
||||
->select(['created_at', 'type', 'amount_rub', 'description'])
|
||||
->get()
|
||||
->map(fn (object $tx): array => [
|
||||
'created_at' => CarbonImmutable::parse($tx->created_at)->toIso8601String(),
|
||||
'type' => $tx->type,
|
||||
'amount_rub' => (string) $tx->amount_rub,
|
||||
'description' => $tx->description,
|
||||
])
|
||||
->all();
|
||||
|
||||
return response()->json([
|
||||
'profile' => [
|
||||
'organization_name' => $tenant->organization_name,
|
||||
'contact_email' => $tenant->contact_email,
|
||||
'contact_name' => $tenant->contact_name,
|
||||
'contact_phone' => $tenant->contact_phone,
|
||||
'inn' => $tenant->inn,
|
||||
'subject_type' => $tenant->subject_type,
|
||||
'created_at' => $tenant->created_at !== null
|
||||
? CarbonImmutable::parse($tenant->created_at)->toIso8601String()
|
||||
: null,
|
||||
'desired_daily_numbers' => $tenant->desired_daily_numbers,
|
||||
'last_activity_at' => $tenant->last_activity_at !== null
|
||||
? CarbonImmutable::parse($tenant->last_activity_at)->toIso8601String()
|
||||
: null,
|
||||
],
|
||||
'kpi' => [
|
||||
'balance_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'projects_count' => $projectsCount,
|
||||
'leads_delivered' => $leadsDelivered,
|
||||
'leads_target' => $leadsTarget,
|
||||
'avg_lead_price_rub' => $avgLeadPriceRub,
|
||||
'earned_rub' => null, // Phase 3: tariff engine
|
||||
],
|
||||
'projects' => $projects,
|
||||
'leads_by_day' => $leadsByDayFormatted,
|
||||
'recent_leads' => $recentLeads,
|
||||
'activity' => $activity,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Маска телефона по 152-ФЗ: видны первые 2 цифры и 2 последних.
|
||||
*
|
||||
* Пример: «79161234567» → «79** *** ** 67»
|
||||
*
|
||||
* Зеркало AdminLeadsController::maskPhone — единый подход к маскированию ПДн.
|
||||
*/
|
||||
private function maskPhone(?string $phone): string
|
||||
{
|
||||
if (! $phone) {
|
||||
return '—';
|
||||
}
|
||||
$digits = preg_replace('/\D/', '', $phone);
|
||||
if (strlen((string) $digits) < 4) {
|
||||
return '***';
|
||||
}
|
||||
$last2 = substr((string) $digits, -2);
|
||||
$first = substr((string) $digits, 0, 2);
|
||||
|
||||
return $first.'** *** ** '.$last2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3312,3 +3312,18 @@ parameters:
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Sales/SalesClientCardTest.php
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Sales/SalesClientCardTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Sales/SalesClientCardTest.php
|
||||
|
||||
@@ -243,6 +243,7 @@ Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () {
|
||||
// Зона данных портала (наполняется в Фазах 1–7).
|
||||
Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales')->group(function () {
|
||||
Route::get('/clients', [SalesClientsController::class, 'index']);
|
||||
Route::get('/clients/{tenantId}', [SalesClientsController::class, 'show'])->whereNumber('tenantId');
|
||||
// attachments, income, tariffs, payouts, invoices, managers, dashboard
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<?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();
|
||||
});
|
||||
Reference in New Issue
Block a user