daa41bcecf
Решение заказчика 03.07.2026: два вида тарифа вместо трёх — суточный оклад daily_salary и процент от пополнений topup_step. Убраны percent_oborot и fix_per_client. Миграция 2026_07_03_120000 меняет CHECK sales_tariffs.kind, освобождает FK sales_users.current_tariff_id и sales_client_assignments.tariff_id, удаляет тарифы уходящих видов. SalesEarningsService для уходящих видов возвращает 0 по default-ветке. Снимки привязок не трогаются. Обновлены контроллеры тарифов и дохода, сервисы Earnings и Metrics, фронт SalesTariffsView и api/sales.ts, демо-сид, тесты бэка и фронта, CHANGELOG схемы v8.61 и спека портала. Не на проде: ветка feat/sales-portal-demo. Проверка: Sales Feature 169/169, Vitest SalesTariffs 10/10, Larastan 0. Co-Authored-By: Claude Opus 4.8 1M context <noreply@anthropic.com>
287 lines
11 KiB
PHP
287 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\SalesClientAssignment;
|
|
use App\Models\SalesPayout;
|
|
use App\Models\SalesUser;
|
|
use App\Models\Tenant;
|
|
use App\Services\Sales\SalesEarningsService;
|
|
use App\Services\Sales\SalesPeriodRange;
|
|
use Carbon\Carbon;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* TDD: SalesManagersController@performance — результативность каждого менеджера (Task 6.2a, бэк).
|
|
*
|
|
* GET /api/sales/managers/performance — строка на каждого менеджера (role=manager) за период.
|
|
*
|
|
* Метод — ТОЛЬКО начальник отдела (role=head). Менеджер → 403.
|
|
*
|
|
* Для каждого менеджера: число клиентов, из них активных (derived-статус 'active'),
|
|
* Σ лидов/оборота за период, Σ всех выплат, доход forManager(period), статус active/vacation.
|
|
*
|
|
* Аутентификация: $this->actingAs($user, 'sales').
|
|
* Изоляция: DatabaseTransactions. Глобально подключён SharesAdminPdo (Pest.php).
|
|
* DB_DATABASE=liderra_testing ОБЯЗАТЕЛЕН при запуске.
|
|
*
|
|
* ⚠️ ВРЕМЯ: замораживаем на день 15 — детерминизм периода/партиций.
|
|
* ⚠️ ПАРТИЦИИ: balance_transactions / lead_charges партиционированы помесячно
|
|
* динамически → сеем ТОЛЬКО в текущий месяц.
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
beforeEach(function () {
|
|
$now = CarbonImmutable::create(null, null, 15, 12, 0, 0, 'Europe/Moscow');
|
|
CarbonImmutable::setTestNow($now);
|
|
Carbon::setTestNow($now);
|
|
});
|
|
|
|
afterEach(function () {
|
|
CarbonImmutable::setTestNow();
|
|
Carbon::setTestNow();
|
|
});
|
|
|
|
// ── helpers (уникальный префикс perf_) ───────────────────────────────────────
|
|
|
|
function perf_head(): SalesUser
|
|
{
|
|
return SalesUser::create([
|
|
'name' => 'Начальник '.uniqid(),
|
|
'email' => 'perfhead'.uniqid().'@sales.local',
|
|
'password' => bcrypt('secret'),
|
|
'role' => 'head',
|
|
'is_active' => true,
|
|
]);
|
|
}
|
|
|
|
function perf_manager(string $name, bool $isActive = true, float $baseSalary = 0.0): SalesUser
|
|
{
|
|
return SalesUser::create([
|
|
'name' => $name,
|
|
'email' => 'perfmgr'.uniqid().'@sales.local',
|
|
'password' => bcrypt('secret'),
|
|
'role' => 'manager',
|
|
'is_active' => $isActive,
|
|
'base_salary_rub' => $baseSalary,
|
|
]);
|
|
}
|
|
|
|
/** Активный клиент: is_trial=false, status=active, баланс≥0, chargeback=0. */
|
|
function perf_activeTenant(): Tenant
|
|
{
|
|
return Tenant::factory()->create([
|
|
'is_trial' => false,
|
|
'status' => 'active',
|
|
'balance_rub' => 1000.0,
|
|
'chargeback_unrecovered_rub' => 0.0,
|
|
'delivered_in_month' => 0,
|
|
]);
|
|
}
|
|
|
|
/** Overdue-клиент: отрицательный баланс → derived-статус 'overdue'. */
|
|
function perf_overdueTenant(): Tenant
|
|
{
|
|
return Tenant::factory()->create([
|
|
'is_trial' => false,
|
|
'status' => 'active',
|
|
'balance_rub' => -500.0,
|
|
'chargeback_unrecovered_rub' => 0.0,
|
|
'delivered_in_month' => 0,
|
|
]);
|
|
}
|
|
|
|
/** @param array<string,mixed> $params */
|
|
function perf_assignment(SalesUser $user, Tenant $tenant, string $kind, array $params): SalesClientAssignment
|
|
{
|
|
return SalesClientAssignment::create([
|
|
'sales_user_id' => $user->id,
|
|
'tenant_id' => $tenant->id,
|
|
'tariff_kind' => $kind,
|
|
'tariff_params' => $params,
|
|
'assigned_at' => Carbon::now('Europe/Moscow')->subMonthsNoOverflow(2)->subDays(15),
|
|
]);
|
|
}
|
|
|
|
/** Дата в ТЕКУЩЕМ месяце МСК (день $day, только Y-m-d). */
|
|
function perf_date(int $day): string
|
|
{
|
|
return CarbonImmutable::now('Europe/Moscow')->startOfMonth()
|
|
->setDay($day)->format('Y-m-d');
|
|
}
|
|
|
|
function perf_leadCharge(int $tenantId, int $kopecks, int $day = 10): void
|
|
{
|
|
$dt = perf_date($day).' 10:00:00';
|
|
DB::table('lead_charges')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'deal_id' => fake()->numberBetween(1, 99999),
|
|
'deal_received_at' => $dt,
|
|
'tier_no' => 1,
|
|
'price_per_lead_kopecks' => $kopecks,
|
|
'charge_source' => 'rub',
|
|
'charged_at' => $dt,
|
|
'created_at' => $dt,
|
|
]);
|
|
}
|
|
|
|
/** Пополнение (topup) — текущий месяц. Доход по тарифу topup_step считается от пополнений. */
|
|
function perf_topup(int $tenantId, string $amountRub, int $day = 10): void
|
|
{
|
|
$dt = perf_date($day).' 10:00:00';
|
|
DB::table('balance_transactions')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'type' => 'topup',
|
|
'amount_rub' => $amountRub,
|
|
'amount_leads' => 0,
|
|
'balance_rub_after' => $amountRub,
|
|
'description' => 'test',
|
|
'created_at' => $dt,
|
|
]);
|
|
}
|
|
|
|
function perf_project(int $tenantId): int
|
|
{
|
|
return (int) DB::table('projects')->insertGetId([
|
|
'tenant_id' => $tenantId,
|
|
'name' => 'Проект '.uniqid(),
|
|
'is_active' => true,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
function perf_deal(int $tenantId, int $projectId, int $day = 10): void
|
|
{
|
|
$dt = perf_date($day).' 10:00:00';
|
|
DB::table('deals')->insert([
|
|
'tenant_id' => $tenantId,
|
|
'project_id' => $projectId,
|
|
'phone' => '7916'.fake()->numerify('#######'),
|
|
'status' => 'new',
|
|
'received_at' => $dt,
|
|
'is_test' => false,
|
|
'created_at' => $dt,
|
|
'updated_at' => $dt,
|
|
]);
|
|
}
|
|
|
|
function perf_payout(int $managerId, int $headId, string $amount, int $day = 12): SalesPayout
|
|
{
|
|
return SalesPayout::create([
|
|
'sales_user_id' => $managerId,
|
|
'amount_rub' => $amount,
|
|
'paid_on' => perf_date($day),
|
|
'created_by' => $headId,
|
|
]);
|
|
}
|
|
|
|
/** Свежий сервис дохода — для ручной сверки earned_rub. */
|
|
function perf_thisMonthRange(): SalesPeriodRange
|
|
{
|
|
$start = CarbonImmutable::now('Europe/Moscow')->startOfMonth();
|
|
|
|
return new SalesPeriodRange(
|
|
$start->startOfDay(),
|
|
$start->endOfMonth()->setTime(23, 59, 59),
|
|
);
|
|
}
|
|
|
|
// ── 1. доступ ────────────────────────────────────────────────────────────────
|
|
|
|
test('менеджер GET /api/sales/managers/performance → 403', function () {
|
|
$manager = perf_manager('Менеджер '.uniqid());
|
|
|
|
$this->actingAs($manager, 'sales')->getJson('/api/sales/managers/performance')
|
|
->assertStatus(403)
|
|
->assertJson(['message' => 'Доступно только начальнику отдела.']);
|
|
});
|
|
|
|
// ── 2. happy path (head) ─────────────────────────────────────────────────────
|
|
|
|
test('head: строка на каждого менеджера с корректными метриками и статусом', function () {
|
|
$head = perf_head();
|
|
|
|
// Менеджер 1 (active, оклад 30 000), 2 клиента: active + overdue.
|
|
$m1 = perf_manager('Алексей Первый', isActive: true, baseSalary: 30000.0);
|
|
$tActive = perf_activeTenant();
|
|
$tOverdue = perf_overdueTenant();
|
|
|
|
// topup_step 20% (оклада нет: у менеджера нет текущего тарифа daily_salary).
|
|
$stepParams = ['threshold' => 0, 'reward' => 0, 'periods' => [['from' => 1, 'to' => 999, 'rate' => 20]]];
|
|
perf_assignment($m1, $tActive, 'topup_step', $stepParams);
|
|
perf_assignment($m1, $tOverdue, 'topup_step', $stepParams);
|
|
|
|
perf_leadCharge($tActive->id, 5_000_000); // оборот 50 000 ₽ (метрика oborot_rub)
|
|
perf_topup($tActive->id, '50000.00'); // пополнения 50 000 ₽ → комиссия 20% = 10 000
|
|
$pActive = perf_project($tActive->id);
|
|
$pOverdue = perf_project($tOverdue->id);
|
|
perf_deal($tActive->id, $pActive);
|
|
perf_deal($tActive->id, $pActive); // 2 лида на active-клиенте
|
|
perf_deal($tOverdue->id, $pOverdue); // 1 лид на overdue-клиенте
|
|
|
|
// Одна выплата (paid_all_time).
|
|
perf_payout($m1->id, $head->id, '7000.00');
|
|
|
|
// Менеджер 2 (is_active=false → vacation), без клиентов.
|
|
$m2 = perf_manager('Борис Второй', isActive: false, baseSalary: 25000.0);
|
|
|
|
$response = $this->actingAs($head, 'sales')->getJson('/api/sales/managers/performance');
|
|
|
|
$response->assertOk();
|
|
$data = collect($response->json('data'));
|
|
|
|
$row1 = $data->firstWhere('manager_id', $m1->id);
|
|
$row2 = $data->firstWhere('manager_id', $m2->id);
|
|
|
|
// earned_rub считаем вручную тем же сервисом (комиссия topup_step 10 000, оклада нет).
|
|
$earned = round(app(SalesEarningsService::class)->forManager($m1->fresh(), perf_thisMonthRange()), 2);
|
|
|
|
expect($row1)->not->toBeNull()
|
|
->and($row1['name'])->toBe('Алексей Первый')
|
|
->and($row1['email'])->toBe($m1->email)
|
|
->and($row1['clients_count'])->toBe(2)
|
|
->and($row1['active_clients'])->toBe(1)
|
|
->and($row1['leads_delivered'])->toBe(3)
|
|
->and((float) $row1['oborot_rub'])->toBe(50000.0)
|
|
->and((float) $row1['paid_all_time_rub'])->toBe(7000.0)
|
|
->and((float) $row1['earned_rub'])->toBe(10000.0)
|
|
->and((float) $row1['earned_rub'])->toBe($earned)
|
|
->and($row1['status'])->toBe('active')
|
|
->and($row1)->toHaveKeys([
|
|
'manager_id', 'name', 'email', 'clients_count', 'active_clients',
|
|
'leads_delivered', 'oborot_rub', 'paid_all_time_rub', 'earned_rub', 'status',
|
|
]);
|
|
|
|
expect($row2)->not->toBeNull()
|
|
->and($row2['clients_count'])->toBe(0)
|
|
->and($row2['active_clients'])->toBe(0)
|
|
->and($row2['status'])->toBe('vacation');
|
|
});
|
|
|
|
// ── 3. search ────────────────────────────────────────────────────────────────
|
|
|
|
test('search по имени менеджера → только совпадающий', function () {
|
|
$head = perf_head();
|
|
|
|
$m1 = perf_manager('Уникальный Зевс', isActive: true);
|
|
$m2 = perf_manager('Другой Аполлон', isActive: true);
|
|
|
|
$data = collect(
|
|
$this->actingAs($head, 'sales')
|
|
->getJson('/api/sales/managers/performance?search=Зевс')
|
|
->json('data')
|
|
);
|
|
|
|
expect($data->firstWhere('manager_id', $m1->id))->not->toBeNull()
|
|
->and($data->firstWhere('manager_id', $m2->id))->toBeNull();
|
|
});
|
|
|
|
// ── 4. неаутентифицированный ─────────────────────────────────────────────────
|
|
|
|
test('неаутентифицированный GET /api/sales/managers/performance → 401', function () {
|
|
$this->getJson('/api/sales/managers/performance')->assertUnauthorized();
|
|
});
|