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>
247 lines
9.6 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|