Files
portal/app/database/seeders/SalesDemoSeeder.php
T
Дмитрий daa41bcecf feat(sales): пересмотр состава тарифов портала продаж — 2 вида
Решение заказчика 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>
2026-07-05 19:25:19 +03:00

247 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\SalesClientAssignment;
use App\Models\SalesPayout;
use App\Models\SalesTariff;
use App\Models\SalesUser;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
/**
* Демо-данные портала отдела продаж — ТОЛЬКО для dev/test.
*
* Наполняет портал так, как в прототипе liderra_v8_handoff/concepts/v8_sales.html:
* начальник + 3 менеджера, 4 тарифных экземпляра (3 вида), привязки клиентов
* (снимок тарифа) и журнал выплат.
*
* Запуск вручную (НЕ регистрируется в DatabaseSeeder):
* php artisan db:seed --class=SalesDemoSeeder
*
* Идемпотентность:
* - тарифы/менеджеры — updateOrCreate/firstOrCreate по естественному ключу;
* - привязки — firstOrCreate по tenant_id (UNIQUE);
* - выплаты (append-only, UPDATE/DELETE запрещены DB-триггером) — создаются
* только если у менеджера ещё нет ни одной выплаты.
*
* Модели работают под DEFAULT connection; в проде портал ходит под admin-db
* (crm_admin_user), на dev/test все connections падают на общий DB_DATABASE,
* поэтому здесь спец-обработка connection не нужна. Тенанты только читаются.
*/
class SalesDemoSeeder extends Seeder
{
public function run(): void
{
if (app()->environment('production')) {
throw new \RuntimeException('SalesDemoSeeder запрещён на production.');
}
$tariffs = $this->seedTariffs();
$managers = $this->seedManagers($tariffs);
$this->seedAssignments($managers);
$this->seedPayouts($managers);
}
/**
* Тарифные экземпляры (2 вида: суточный оклад + процент от пополнений с порогом/бонусом).
* Ключ идемпотентности — (kind, name).
*
* @return array<string, SalesTariff>
*/
private function seedTariffs(): array
{
// Суточный оклад — маркер режима; ставка ₽/день задаётся per-manager (base_salary_rub).
$daily = SalesTariff::updateOrCreate(
['kind' => 'daily_salary', 'name' => 'Суточный оклад'],
['is_active' => true, 'params' => []],
);
// Процент от пополнений: порог 30 000 → бонус 10 000, дальше ступени по сроку.
$stepStd = SalesTariff::updateOrCreate(
['kind' => 'topup_step', 'name' => 'Стандарт-пополнения'],
['is_active' => true, 'params' => [
'threshold' => 30000,
'reward' => 10000,
'periods' => [
['from' => 1, 'to' => 3, 'rate' => 10],
['from' => 4, 'to' => 6, 'rate' => 7],
['from' => 7, 'to' => 9, 'rate' => 5],
['from' => 10, 'to' => 12, 'rate' => 3],
['from' => 13, 'to' => 24, 'rate' => 1],
],
]],
);
// Агрессивный: порог ниже (20 000), бонус выше (15 000), ставки выше.
$stepAgro = SalesTariff::updateOrCreate(
['kind' => 'topup_step', 'name' => 'Агрессивный'],
['is_active' => true, 'params' => [
'threshold' => 20000,
'reward' => 15000,
'periods' => [
['from' => 1, 'to' => 3, 'rate' => 12],
['from' => 4, 'to' => 6, 'rate' => 9],
['from' => 7, 'to' => 9, 'rate' => 6],
['from' => 10, 'to' => 12, 'rate' => 4],
['from' => 13, 'to' => 24, 'rate' => 2],
],
]],
);
return [
'daily' => $daily,
'step_std' => $stepStd,
'step_agro' => $stepAgro,
];
}
/**
* Начальник + 3 менеджера. Ключ идемпотентности — email.
*
* @param array<string, SalesTariff> $tariffs
* @return array<string, SalesUser>
*/
private function seedManagers(array $tariffs): array
{
$password = Hash::make('demo12345');
$head = SalesUser::updateOrCreate(
['email' => 'v.orlov@liderra.ru'],
[
'name' => 'Виктор Орлов',
'password' => $password,
'role' => 'head',
'is_active' => true,
'base_salary_rub' => 0,
'current_tariff_id' => null,
'created_by' => null,
],
);
// Новичок на суточном окладе: ставка 2 000 ₽/день (base_salary_rub).
$smirnov = SalesUser::updateOrCreate(
['email' => 'a.smirnov@liderra.ru'],
[
'name' => 'Алексей Смирнов',
'password' => $password,
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => 2000,
'current_tariff_id' => $tariffs['daily']->id,
'created_by' => $head->id,
],
);
// Опытный на проценте от пополнений (Стандарт).
$kotova = SalesUser::updateOrCreate(
['email' => 'm.kotova@liderra.ru'],
[
'name' => 'Мария Котова',
'password' => $password,
'role' => 'manager',
'is_active' => true,
'base_salary_rub' => 0,
'current_tariff_id' => $tariffs['step_std']->id,
'created_by' => $head->id,
],
);
$lebedev = SalesUser::updateOrCreate(
['email' => 'i.lebedev@liderra.ru'],
[
'name' => 'Игорь Лебедев',
'password' => $password,
'role' => 'manager',
'is_active' => false, // «отпуск»
'base_salary_rub' => 0,
'current_tariff_id' => $tariffs['step_agro']->id,
'created_by' => $head->id,
],
);
return [
'head' => $head,
'smirnov' => $smirnov,
'kotova' => $kotova,
'lebedev' => $lebedev,
];
}
/**
* Привязки клиентов со СНИМКОМ текущего тарифа менеджера.
* Берём существующие тенанты; если их нет — пропускаем (не падаем).
*
* @param array<string, SalesUser> $managers
*/
private function seedAssignments(array $managers): void
{
/** @var Collection<int, Tenant> $tenants */
$tenants = Tenant::query()->limit(8)->get();
if ($tenants->isEmpty()) {
$this->command->warn('SalesDemoSeeder: тенантов нет — привязки клиентов пропущены.');
return;
}
// Распределяем тенанты по 3 менеджерам по кругу.
$roundRobin = [$managers['smirnov'], $managers['kotova'], $managers['lebedev']];
foreach ($tenants as $index => $tenant) {
$manager = $roundRobin[$index % count($roundRobin)];
$tariff = $manager->currentTariff; // снимок берём из тарифа менеджера
SalesClientAssignment::firstOrCreate(
['tenant_id' => $tenant->id], // UNIQUE — повторный запуск не дублирует
[
'sales_user_id' => $manager->id,
'tariff_id' => $tariff->id,
'tariff_kind' => $tariff->kind,
'tariff_params' => $tariff->params,
'assigned_at' => now()->subMonthsNoOverflow(rand(1, 6)),
],
);
}
}
/**
* Выплаты (append-only) — по 1-2 на менеджера, суммы из прототипа.
* Создаём только если у менеджера ещё нет выплат (нельзя дублировать).
*
* @param array<string, SalesUser> $managers
*/
private function seedPayouts(array $managers): void
{
$head = $managers['head'];
$plan = [
'smirnov' => [35000, 25000],
'kotova' => [30000],
'lebedev' => [40000],
];
foreach ($plan as $key => $amounts) {
$manager = $managers[$key];
if ($manager->payouts()->count() > 0) {
continue; // уже есть выплаты — не дублируем append-only записи
}
foreach ($amounts as $offset => $amount) {
SalesPayout::create([
'sales_user_id' => $manager->id,
'amount_rub' => $amount,
'paid_on' => now()->subMonthsNoOverflow($offset)->startOfMonth()->addDays(24),
'comment' => $offset === 0 ? 'Зарплата (демо)' : 'Аванс (демо)',
'created_by' => $head->id,
]);
}
}
}
}