feat(sales): эндпоинт «Мои клиенты» (бэкенд)

Task 1.3a: GET /api/sales/clients — менеджер видит своих (ScopesSalesOwnership), начальник всех. Строки: организация, ИНН/тип лица (tenant_requisites), баланс, запас, проекты, лиды/оборот за период (SalesMetricsService), тариф-снимок из assignment, статус 1:1 с AdminTenantsController (trial>suspended>overdue>active). earned_rub=null до Фазы 3. Тест 7/7, stan 0 (baseline: Pest false-pos). Пагинация — TODO. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-02 06:34:22 +03:00
parent 05bf7ef1b8
commit ad975c4d44
4 changed files with 465 additions and 1 deletions
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
use App\Http\Controllers\Controller;
use App\Models\SalesUser;
use App\Services\Sales\SalesMetricsService;
use App\Services\Sales\SalesPeriodResolver;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Портал продаж экран «Мои клиенты».
*
* GET /api/sales/clients
*
* Менеджер видит только своих клиентов (через 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)
*/
class SalesClientsController extends Controller
{
use ScopesSalesOwnership;
/**
* Список клиентов с метриками периода.
*/
public function index(Request $request): JsonResponse
{
/** @var SalesUser $user */
$user = $request->user('sales');
// 1. Период
$period = app(SalesPeriodResolver::class)->resolve([
'kind' => $request->query('period', 'this'),
'from' => $request->query('from'),
'to' => $request->query('to'),
]);
// 2. Tenant scope
$ids = $this->ownedTenantIds($user);
// 3. Базовый запрос: tenants + LEFT JOIN tenant_requisites + LEFT JOIN assignment + tariff
$query = DB::table('tenants')
->leftJoin('tenant_requisites', 'tenant_requisites.tenant_id', '=', 'tenants.id')
->leftJoin('sales_client_assignments as sca', 'sca.tenant_id', '=', 'tenants.id')
->leftJoin('sales_tariffs as st', 'st.id', '=', 'sca.tariff_id')
->whereNull('tenants.deleted_at')
->select([
'tenants.id as tenant_id',
'tenants.organization_name',
'tenants.status',
'tenants.is_trial',
'tenants.balance_rub',
'tenants.chargeback_unrecovered_rub',
'tenants.last_activity_at',
'tenant_requisites.inn',
'tenant_requisites.subject_type',
'st.name as tariff_name',
]);
// Ограничение по владению: null = начальник (без ограничения)
if ($ids !== null) {
$query->whereIn('tenants.id', $ids === [] ? [-1] : $ids);
}
// Поиск
$search = trim((string) $request->query('search', ''));
if ($search !== '') {
$like = '%'.$search.'%';
$query->where(function ($q) use ($like): void {
$q->where('tenants.organization_name', 'ilike', $like)
->orWhere('tenant_requisites.inn', 'ilike', $like);
});
}
$rows = $query
->orderByDesc('tenants.last_activity_at')
->orderBy('tenants.id')
->get();
$metrics = app(SalesMetricsService::class);
$data = $rows->map(function (object $row) use ($metrics, $period): array {
$tenantId = (int) $row->tenant_id;
// projects_count: все проекты тенанта (без фильтра по is_active/archived).
// Counting all projects per tenant — active filter can be added if spec clarified.
$projectsCount = DB::table('projects')
->where('tenant_id', $tenantId)
->count();
// Производный статус — зеркалит AdminTenantsController CASE-логику:
// trial > suspended > overdue > active > else raw status.
$derivedStatus = match (true) {
(bool) $row->is_trial => 'trial',
$row->status === 'suspended' => 'suspended',
(float) $row->chargeback_unrecovered_rub > 0 || (float) $row->balance_rub < 0 => 'overdue',
$row->status === 'active' => 'active',
default => (string) $row->status,
};
return [
'tenant_id' => $tenantId,
'organization_name' => $row->organization_name,
'inn' => $row->inn,
'subject_type' => $row->subject_type,
'last_activity_at' => $row->last_activity_at !== null
? CarbonImmutable::parse($row->last_activity_at)->toIso8601String()
: null,
'balance_rub' => (string) $row->balance_rub,
'status' => $derivedStatus,
'tariff_name' => $row->tariff_name,
'projects_count' => $projectsCount,
'runway_days' => $metrics->runwayDays($tenantId),
'leads_delivered' => $metrics->leadsDelivered($tenantId, $period),
'oborot_rub' => $metrics->oborotRub($tenantId, $period),
'earned_rub' => null, // Phase 3: tariff engine
];
})->all();
return response()->json(['data' => $data]);
}
}
+18
View File
@@ -276,6 +276,24 @@ parameters:
count: 3
path: tests/Feature/Sales/SalesAuthTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:withHeader\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<list\|null\>\:\:\$not\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Sales/SalesClientsIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
+3 -1
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\Sales\SalesAuthController;
use App\Http\Controllers\Api\Sales\SalesClientsController;
use Illuminate\Support\Facades\Route;
// Laravel 13 string-based lazy-loading контроллеров (Sprint 2 Phase A, O-stack-03).
@@ -241,7 +242,8 @@ 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 () {
// clients, attachments, income, tariffs, payouts, invoices, managers, dashboard
Route::get('/clients', [SalesClientsController::class, 'index']);
// attachments, income, tariffs, payouts, invoices, managers, dashboard
});
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
use App\Models\SalesClientAssignment;
use App\Models\SalesTariff;
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 экран «Мои клиенты».
*
* Покрывает Task 1.3 портала продаж:
* - Менеджер видит только своих клиентов.
* - Начальник видит всех.
* - Менеджер без клиентов видит 0.
* - Строка ответа содержит organization_name, inn, status, tariff_name, earned_rub=null.
* - Параметр period влияет на leads_delivered.
*
* Изоляция: DatabaseTransactions откат в конце каждого теста.
* SharesAdminPdo применяется глобально в Pest.php admin-db middleware
* переключает default→pgsql_admin, sharing PDO обеспечивает видимость
* засеянных данных в запросах.
*
* Аутентификация: создаём SalesUser и передаём Bearer-токен вручную через
* $user->createToken('sales')->plainTextToken.
*
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3)
*/
uses(DatabaseTransactions::class);
// ── helpers (prefixed с clients_ чтобы не конфликтовать с SalesModelsTest) ───
/**
* Создаёт SalesUser для тестов clients-эндпоинта.
*/
function clients_makeSalesUser(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => ucfirst($role).' '.uniqid(),
'email' => 'clients-test-'.$role.uniqid().'@test.local',
'password' => Hash::make('secret'),
'role' => $role,
'is_active' => true,
]);
}
/**
* Выполняет GET /api/sales/clients с Bearer-токеном пользователя.
*
* @param array<string,mixed> $params
*/
function clients_getClients(SalesUser $user, array $params = []): TestResponse
{
$token = $user->createToken('sales')->plainTextToken;
$url = '/api/sales/clients';
if ($params !== []) {
$url .= '?'.http_build_query($params);
}
return test()->withHeader('Authorization', 'Bearer '.$token)->getJson($url);
}
/**
* Создаёт тенанта с tenant_requisites (inn).
*/
function clients_makeTenantWithInn(string $inn, string $orgName = ''): Tenant
{
$tenant = Tenant::factory()->create([
'organization_name' => $orgName !== '' ? $orgName : 'Org '.$inn,
'status' => 'active',
'is_trial' => false,
'balance_rub' => '0.00',
'chargeback_unrecovered_rub' => '0.00',
]);
DB::table('tenant_requisites')->insert([
'tenant_id' => $tenant->id,
'subject_type' => 'legal_entity',
'contact_name' => 'Контакт',
'contact_phone' => '+70000000000',
'inn' => $inn,
'created_at' => now(),
'updated_at' => now(),
]);
return $tenant;
}
/**
* Присваивает тенанта менеджеру с опциональным снимком тарифа.
*/
function clients_assignTenant(SalesUser $manager, Tenant $tenant, ?SalesTariff $tariff = null): SalesClientAssignment
{
return SalesClientAssignment::create([
'sales_user_id' => $manager->id,
'tenant_id' => $tenant->id,
'tariff_id' => $tariff?->id,
'tariff_kind' => $tariff?->kind,
'tariff_params' => $tariff !== null ? $tariff->params : [],
'assigned_at' => now(),
]);
}
/**
* Вставляет deal для теста period-фильтра.
*/
function clients_insertDeal(int $tenantId, string $receivedAt): void
{
// Создаём проект если нет
$projectId = (int) DB::table('projects')
->where('tenant_id', $tenantId)
->value('id');
if ($projectId === 0) {
$projectId = (int) DB::table('projects')->insertGetId([
'tenant_id' => $tenantId,
'name' => 'Period Test Project',
'tag' => 'period-test-'.uniqid(),
'is_active' => true,
'daily_limit_target' => 10,
'delivery_days_mask' => 127,
'created_at' => now(),
'updated_at' => now(),
]);
}
DB::table('deals')->insert([
'tenant_id' => $tenantId,
'project_id' => $projectId,
'source_crm_id' => rand(100_000_000, 999_999_999),
'phone' => '7'.str_pad((string) rand(0, 9_999_999_999), 10, '0', STR_PAD_LEFT),
'status' => 'new',
'is_test' => false,
'received_at' => $receivedAt,
'created_at' => now(),
'updated_at' => now(),
]);
}
// ── тесты ────────────────────────────────────────────────────────────────────
test('менеджер видит только своих 2 клиентов из 3', function () {
$manager = clients_makeSalesUser('manager');
$manager2 = clients_makeSalesUser('manager');
$t1 = clients_makeTenantWithInn('001', 'Клиент 1');
$t2 = clients_makeTenantWithInn('002', 'Клиент 2');
$t3 = clients_makeTenantWithInn('003', 'Клиент 3');
clients_assignTenant($manager, $t1);
clients_assignTenant($manager, $t2);
clients_assignTenant($manager2, $t3);
$response = clients_getClients($manager);
$response->assertOk();
$data = $response->json('data');
expect($data)->toBeArray()->toHaveCount(2);
$tenantIds = array_column($data, 'tenant_id');
expect($tenantIds)->toContain($t1->id)->toContain($t2->id)
->not->toContain($t3->id);
});
test('начальник видит всех 3 клиентов', function () {
$head = clients_makeSalesUser('head');
$manager = clients_makeSalesUser('manager');
$t1 = clients_makeTenantWithInn('101');
$t2 = clients_makeTenantWithInn('102');
$t3 = clients_makeTenantWithInn('103');
clients_assignTenant($manager, $t1);
clients_assignTenant($manager, $t2);
clients_assignTenant($manager, $t3);
$response = clients_getClients($head);
$response->assertOk();
$data = $response->json('data');
$tenantIds = array_column($data, 'tenant_id');
expect($tenantIds)->toContain($t1->id)->toContain($t2->id)->toContain($t3->id);
});
test('менеджер без назначений видит 0 клиентов', function () {
$manager2 = clients_makeSalesUser('manager');
$manager1 = clients_makeSalesUser('manager');
$t1 = clients_makeTenantWithInn('201');
clients_assignTenant($manager1, $t1);
$response = clients_getClients($manager2);
$response->assertOk();
expect($response->json('data'))->toBeArray()->toHaveCount(0);
});
test('строка ответа содержит нужные поля и earned_rub=null', function () {
$manager = clients_makeSalesUser('manager');
$tariff = SalesTariff::create([
'name' => 'Тариф Тест',
'kind' => 'percent_oborot',
'params' => ['percent' => 10],
'is_active' => true,
]);
$t1 = clients_makeTenantWithInn('301', 'ООО Тест');
clients_assignTenant($manager, $t1, $tariff);
$response = clients_getClients($manager);
$response->assertOk();
$data = $response->json('data');
expect($data)->toHaveCount(1);
$row = $data[0];
expect($row)->toHaveKey('tenant_id')
->toHaveKey('organization_name')
->toHaveKey('inn')
->toHaveKey('status')
->toHaveKey('tariff_name')
->toHaveKey('earned_rub')
->toHaveKey('leads_delivered')
->toHaveKey('oborot_rub')
->toHaveKey('runway_days')
->toHaveKey('projects_count');
expect($row['organization_name'])->toBe('ООО Тест');
expect($row['inn'])->toBe('301');
expect($row['tariff_name'])->toBe('Тариф Тест');
expect($row['earned_rub'])->toBeNull();
});
test('status derivation: is_trial → trial, suspended → suspended, balance_rub < 0 → overdue, active → active', function () {
$head = clients_makeSalesUser('head');
$manager = clients_makeSalesUser('manager');
$tTrial = Tenant::factory()->create(['is_trial' => true, 'status' => 'active', 'balance_rub' => '0.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tTrial);
$tSuspended = Tenant::factory()->create(['is_trial' => false, 'status' => 'suspended', 'balance_rub' => '0.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tSuspended);
$tOverdue = Tenant::factory()->create(['is_trial' => false, 'status' => 'active', 'balance_rub' => '-100.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tOverdue);
$tActive = Tenant::factory()->create(['is_trial' => false, 'status' => 'active', 'balance_rub' => '500.00', 'chargeback_unrecovered_rub' => '0.00']);
clients_assignTenant($manager, $tActive);
$response = clients_getClients($head);
$response->assertOk();
$data = $response->json('data');
$byId = [];
foreach ($data as $row) {
$byId[$row['tenant_id']] = $row['status'];
}
expect($byId[$tTrial->id])->toBe('trial');
expect($byId[$tSuspended->id])->toBe('suspended');
expect($byId[$tOverdue->id])->toBe('overdue');
expect($byId[$tActive->id])->toBe('active');
});
test('period=this включает лиды текущего месяца, period=prev — предыдущего', function () {
$manager = clients_makeSalesUser('manager');
$tenant = clients_makeTenantWithInn('401', 'Тест период');
clients_assignTenant($manager, $tenant);
$nowMsk = CarbonImmutable::now('Europe/Moscow');
// Лид в текущем месяце (сегодня)
$inRange = $nowMsk->format('Y-m-d H:i:s');
// Лид в прошлом месяце (вне текущего периода)
$prevMonth = $nowMsk->subMonth()->startOfMonth()->format('Y-m-d H:i:s');
clients_insertDeal($tenant->id, $inRange);
clients_insertDeal($tenant->id, $prevMonth);
// period=this — 1 лид (только текущий месяц)
$respThis = clients_getClients($manager, ['period' => 'this']);
$respThis->assertOk();
$dataThis = $respThis->json('data');
expect($dataThis)->toHaveCount(1);
expect($dataThis[0]['leads_delivered'])->toBe(1);
// period=prev — 1 лид (прошлый месяц)
$respPrev = clients_getClients($manager, ['period' => 'prev']);
$respPrev->assertOk();
$dataPrev = $respPrev->json('data');
expect($dataPrev)->toHaveCount(1);
expect($dataPrev[0]['leads_delivered'])->toBe(1);
});
test('запрос без токена → 401', function () {
$this->getJson('/api/sales/clients')
->assertUnauthorized();
});