Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 612bf71928 | |||
| f07897a0f7 | |||
| f6760b74ff | |||
| 7f5902d610 | |||
| 7230e86f36 | |||
| 8e40e1e76b | |||
| b6654f8a9e | |||
| cedbb9de92 | |||
| 41fb1e9d02 | |||
| 0ef791b6e2 | |||
| 5e8e58d1d1 | |||
| 9fd4459e2f | |||
| 760956e4a7 | |||
| 9ef8eccf08 | |||
| a85148555c | |||
| ad975c4d44 | |||
| 05bf7ef1b8 | |||
| 4a0e26af09 | |||
| 7e8a2dc86a | |||
| 4e6ac1057f | |||
| 55c14fc7c2 | |||
| 07b5758291 | |||
| ef0f7c803f | |||
| 86bbeb1f06 | |||
| c366614fcd | |||
| 372668ad41 | |||
| bdcb82f8f7 | |||
| 9b91016f46 | |||
| b0794fbef6 | |||
| 5a33074dbf | |||
| 7067c583ec | |||
| 2d5e52799e | |||
| 45d67f3322 | |||
| ad519c89c8 | |||
| 9737ea7b1b | |||
| adfdf9583c | |||
| 0f1bced2a5 | |||
| a56dcb06b2 | |||
| f45cfb900c | |||
| dab91b62f7 | |||
| fea4b47ecb | |||
| 628423322a | |||
| e8491e81de |
@@ -4,6 +4,12 @@
|
||||
// её «пересобирать». Только инъекция контекста, ничего не блокирует.
|
||||
|
||||
const context = [
|
||||
'🔴🔴🔴 БОЕВОЙ ПРОД liderra.ru — ЖИВЫЕ КЛИЕНТЫ И ДЕНЬГИ. 🔴🔴🔴',
|
||||
'Любой доступ/изменение боевого (БД, деплой, джобы, кабинет поставщика) —',
|
||||
'ТОЛЬКО с явного разрешения владельца. По умолчанию БД — только чтение.',
|
||||
'ЛК поставщика на проде = crm.lead.store (логин omega.gzk); локально/тесты = crm.bp-gr.ru.',
|
||||
'Снимок боевого — ПИЛОТ.md. Снос базы — только маркер PROD-DESTROY-OK + свежий бэкап.',
|
||||
'',
|
||||
'ОРИЕНТИР ПО БАЗЕ ЛИДЕРРЫ (важно перед любой работой с БД):',
|
||||
'- ЖИВАЯ боевая база = Yandex Managed PG, кластер c9q2cvtjpq3hgq6l0r96',
|
||||
' (rw-endpoint *.rw.mdb.yandexcloud.net:6432). Доступ — через app/.env',
|
||||
|
||||
@@ -119,7 +119,14 @@ paths = [
|
||||
'''tools/observer-pii-filter\.test\.mjs''',
|
||||
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
|
||||
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
|
||||
'''tools/enforce-read-path-deny\.test\.mjs'''
|
||||
'''tools/enforce-read-path-deny\.test\.mjs''',
|
||||
# Заглушка ИИ-агента автоподбора (Fake*CompetitorAgent) — синтетические демо-телефоны
|
||||
# конкурентов (Казань 8432…, 8-800), а не реальные ПДн. Та же категория, что
|
||||
# factories/doubles; заменяется реальным движком (binding в AutopodborServiceProvider).
|
||||
'''app/app/Services/Autopodbor/Agent/Fake.*Agent\.php''',
|
||||
# Кликабельные прототипы фичи (демо-телефоны для визуализации макета) — та же категория,
|
||||
# что docs/superpowers/{specs,plans,audits,runbooks}; не реальные ПДн.
|
||||
'''docs/superpowers/prototypes/.*\.html'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
@@ -167,5 +174,8 @@ regexes = [
|
||||
'''\+79991234567''',
|
||||
'''7 999 123 45 67''',
|
||||
# 12-значные номера-маски для скриншотов и тестов
|
||||
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}'''
|
||||
'''[78]\(?[*X]{3}\)?\s?[*X]{3}[\s\-]?[*X]{2}[\s\-]?[*X0-9]{2}''',
|
||||
# Демо-плейсхолдер автоподбора (экран DetailScreen) — Казань 843 + «200-00-00», явный фейк
|
||||
'''7\s?843\s?200[\s\-]?00[\s\-]?00''',
|
||||
'''78432000000'''
|
||||
]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
## ⛔ ГЛАВНОЕ — прочитать первым делом
|
||||
|
||||
> 🔴🔴🔴 **БОЕВОЙ ПРОД liderra.ru — ЖИВЫЕ КЛИЕНТЫ И ДЕНЬГИ.** Любой доступ/изменение боевого
|
||||
> (БД, деплой, джобы, кабинет поставщика) — **только с явного разрешения владельца**; БД по
|
||||
> умолчанию **только чтение**. ЛК поставщика: на проде = **crm.lead.store**, локально/тесты =
|
||||
> crm.bp-gr.ru. Снос базы — только маркер `PROD-DESTROY-OK` + свежий бэкап. Снимок боевого —
|
||||
> `ПИЛОТ.md`; состояние (01.07.2026): база чиста и взведена для боевой работы. 🔴🔴🔴
|
||||
|
||||
1. **Не уверен — спроси, не гадай.** Один вопрос лучше, чем час работы не туда.
|
||||
2. **Не выдумывай.** Не помнишь — открой файл и проверь, а не «вспоминай по памяти».
|
||||
3. **«Готово» — только если правда проверил.** Что-то упало — скажи честно, не делай вид, что всё хорошо.
|
||||
@@ -13,7 +19,7 @@
|
||||
|
||||
# CLAUDE.md — техконтекст Лидерры
|
||||
|
||||
**Версия:** 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
|
||||
**Версия:** 2.48 от 01.07.2026 — в §ГЛАВНОЕ добавлен горящий баннер «БОЕВОЙ ПРОД» (доступ только с разрешения владельца, БД по умолчанию только чтение, ЛК поставщика на проде = crm.lead.store, снос базы только по PROD-DESTROY-OK); прод очищен «с нуля» и взведён для боевой работы 01.07.2026 (см. `ПИЛОТ.md` + план `docs/superpowers/plans/2026-07-01-prod-cleanup-supplier-lk-swap.md`). Прежняя запись: 2.47 от 15.06.2026 — структурная компактизация: история версий и журнал фаз вынесены в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md); разделы про «мозг» (router / наставник / observer / enforcement / разработка реестра инструментов) убраны — управляющий слой выделен в отдельный репозиторий **claude-brain** (ADR-020). Правила, нормативка и состав продукта **не изменены** — только структура файла. Полная история — в CHANGELOG. (Прежняя ремарка про рассинхрон cross-ref квинтета на 2.47 снята — закрыто в PSR v3.24 / Tooling v2.25 от 14.06.2026.)
|
||||
|
||||
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
|
||||
**Владелец и режим правок:** все изменения этого файла — **только** через плагин `claude-md-management` (skills `/claude-md-management:claude-md-improver` для audit/targeted-updates и `/claude-md-management:revise-claude-md` для capture session-learnings). Прямые правки запрещены — см. §5 п.11.
|
||||
@@ -245,7 +251,7 @@ trivy image liderra:latest
|
||||
|
||||
**Полный журнал фаз и работ** (что и когда делалось, включая историю «мозга») — в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md).
|
||||
|
||||
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО), договор с **ЮKassa** готов — осталось только подписать; после подписи включается онлайн-оплата (флаг `billing_yookassa_enabled`). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источник истины — память `project-legal-entity-ip-yookassa-2026-06-25` (25.06.2026).
|
||||
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО). Договор с **ЮKassa подписан 26.06.2026** (№НЭК.448000.01), магазин 1392092 активен. Флаг `billing_yookassa_enabled` — **ВКЛЮЧЁН (намеренно, штатно)**, но **go-live онлайн-оплаты НЕ завершён:** успешной живой оплаты ещё не было (5 тестовых попыток 100₽ 26–27.06 отменены на стороне ЮKassa (`canceled`/`paid=false`, у P6 — `expired_on_confirmation`), деньги не списаны; happy-path «оплата→webhook→зачисление» в бою не проверялся; webhook IP-allowlist пуст). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источники истины — память `project-yookassa-online-payment-golive-2026-06-26` + снимок ПИЛОТ.md 27.06.2026.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -411,7 +411,6 @@ class AdminDashboardController extends Controller
|
||||
'dadata' => (string) config('services.dadata.topup_url') ?: null,
|
||||
'supplier' => (string) config('services.supplier.topup_url') ?: null,
|
||||
'yandex_cloud' => $this->ycTopupUrl(),
|
||||
'email' => (string) config('services.yandex360.topup_url') ?: null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,21 +31,12 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminSystemSettingsController extends Controller
|
||||
{
|
||||
/** Ключи-секреты: значение не отдаём в общий список (хранится зашифрованно). */
|
||||
private const SENSITIVE_KEYS = ['supplier_webhook_secret'];
|
||||
|
||||
/** GET /api/admin/system-settings */
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$settings = SystemSetting::orderBy('key')->get()->map(function (SystemSetting $s) {
|
||||
if (in_array($s->key, self::SENSITIVE_KEYS, true)) {
|
||||
$s->value = $s->value === '' ? '' : '••• (скрыто)';
|
||||
}
|
||||
|
||||
return $s;
|
||||
});
|
||||
|
||||
return response()->json(['settings' => $settings]);
|
||||
return response()->json([
|
||||
'settings' => SystemSetting::orderBy('key')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** PUT /api/admin/system-settings/{key} */
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\External\Yandex360BalanceStore;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* SaaS-admin → Система: ручной ввод баланса Яндекс 360 (почта).
|
||||
* Владелец периодически вписывает сумму из кабинета (у Яндекса нет API баланса).
|
||||
*/
|
||||
class AdminYandex360BalanceController extends Controller
|
||||
{
|
||||
public function __construct(private readonly Yandex360BalanceStore $store) {}
|
||||
|
||||
/** GET /api/admin/settings/yandex360-balance */
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->json($this->store->status());
|
||||
}
|
||||
|
||||
/** PUT /api/admin/settings/yandex360-balance — {balance:number|null} */
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$raw = $request->input('balance');
|
||||
$balance = ($raw === null || $raw === '') ? null : (float) $raw;
|
||||
if ($balance !== null && $balance < 0) {
|
||||
return response()->json([
|
||||
'message' => 'Баланс не может быть отрицательным.',
|
||||
'errors' => ['balance' => ['Введите сумму ≥ 0 или очистите поле.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->store->set($balance);
|
||||
|
||||
return response()->json($this->store->status());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use App\Support\SupplierProjectName;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -211,7 +212,7 @@ class DealController extends Controller
|
||||
'id' => $d->id,
|
||||
'tenant_id' => $d->tenant_id,
|
||||
'project_id' => $d->project_id,
|
||||
'project_name' => $d->project?->name,
|
||||
'project_name' => SupplierProjectName::strip($d->project?->name),
|
||||
'phone' => $d->phone,
|
||||
'contact_name' => $d->contact_name,
|
||||
'status' => $d->status,
|
||||
@@ -308,7 +309,7 @@ class DealController extends Controller
|
||||
'id' => $deal->id,
|
||||
'tenant_id' => $deal->tenant_id,
|
||||
'project_id' => $deal->project_id,
|
||||
'project_name' => $deal->project?->name,
|
||||
'project_name' => SupplierProjectName::strip($deal->project?->name),
|
||||
'phone' => $deal->phone,
|
||||
'contact_name' => $deal->contact_name,
|
||||
'comment' => $deal->comment,
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Support\CsvFormulaGuard;
|
||||
use App\Support\SupplierProjectName;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -121,7 +122,7 @@ class DealExportController extends Controller
|
||||
foreach ($deals as $deal) {
|
||||
/** @var Deal $deal */
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
$source = trim((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
// F-CSV: свободный текст (телефон/источник/город/статус/
|
||||
// комментарий) экранируем от formula-инъекции. Дата —
|
||||
|
||||
@@ -9,14 +9,15 @@ use App\Http\Requests\BulkProjectActionRequest;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LaunchBalanceGate;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Project\ProjectService;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Проекты tenant'а — расширенный API для ProjectsView + NewDealDialog.
|
||||
@@ -133,18 +134,35 @@ class ProjectController extends Controller
|
||||
return response()->json(['error' => 'requisites_required'], 422);
|
||||
}
|
||||
|
||||
unset($validated['force_save_blocked']); // больше не блокируем создание
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
$project = $this->projects->create($tenant, $validated, launch: true);
|
||||
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
return response()->json([
|
||||
'data' => new ProjectResource($project->loadCount('supplierProjects')),
|
||||
'launch' => [
|
||||
'launched' => $project->launch_deferred ? 0 : 1,
|
||||
'deferred' => $project->launch_deferred ? 1 : 0,
|
||||
'balance' => $project->gate_payload,
|
||||
],
|
||||
], 201);
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id} */
|
||||
@@ -153,28 +171,34 @@ class ProjectController extends Controller
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
$isLimitRaise = array_key_exists('daily_limit_target', $validated)
|
||||
&& (int) $validated['daily_limit_target'] > (int) $project->daily_limit_target
|
||||
&& $project->is_active && $project->preflight_blocked_at === null;
|
||||
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
|
||||
// проекта + лимиты остальных активных не-blocked.
|
||||
if (array_key_exists('daily_limit_target', $validated)) {
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('id', '!=', $project->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
if ($isLimitRaise) {
|
||||
$newLimit = (int) $validated['daily_limit_target'];
|
||||
$tenantId = $tenant->id;
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
return DB::transaction(function () use ($project, $validated, $tenant, $tenantId, $newLimit): JsonResponse {
|
||||
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
$gate = app(LaunchBalanceGate::class)->evaluate($tenant, $newLimit, [$project->id]);
|
||||
if (! $gate->passes) {
|
||||
return response()->json(['error' => 'balance_insufficient', 'balance' => $gate->toBalancePayload()], 409);
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
});
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
@@ -182,6 +206,34 @@ class ProjectController extends Controller
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
|
||||
if ($tiers->isEmpty()) {
|
||||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||||
}
|
||||
|
||||
$result = (new BalancePreflightService)->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $requiredLeads,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
return [
|
||||
'passes' => $result->passes,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'deficit_leads' => $result->deficitLeads,
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/projects/{id} */
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
@@ -217,13 +269,23 @@ class ProjectController extends Controller
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
|
||||
$result = $this->projects->setActive($project, $request->boolean('is_active'));
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
||||
$newActive = $request->boolean('is_active');
|
||||
$project->update([
|
||||
'is_active' => $newActive,
|
||||
'paused_at' => $newActive ? null : now(),
|
||||
]);
|
||||
|
||||
if ($result->activate_deferred ?? false) {
|
||||
return response()->json(['error' => 'balance_insufficient', 'balance' => $result->gate_payload], 409);
|
||||
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
// G (балансовый блок): заблокированный за нехваткой баланса проект не
|
||||
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
|
||||
if ($project->preflight_blocked_at === null) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
|
||||
return response()->json(['data' => new ProjectResource($result->loadCount('supplierProjects'))]);
|
||||
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Sales;
|
||||
|
||||
use App\Models\SalesUser;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* Аутентификация портала отдела продаж.
|
||||
*
|
||||
* Все маршруты идут через middleware admin-db (UseAdminConnection),
|
||||
* который переключает default-соединение на pgsql_admin (crm_admin_user).
|
||||
* Это необходимо, потому что sales_users и personal_access_tokens доступны
|
||||
* crm_admin_user, а Sanctum читает токены ДО контроллера — в middleware auth:sales.
|
||||
*
|
||||
* guard: 'sales' (Sanctum, provider sales_users) — см. config/auth.php.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.5)
|
||||
*/
|
||||
class SalesAuthController extends Controller
|
||||
{
|
||||
/**
|
||||
* Вход менеджера / руководителя продаж.
|
||||
*
|
||||
* Валидация: email (required, email) + password (required, string).
|
||||
* Ошибки: 422 неверные учётные данные, 403 аккаунт отключён.
|
||||
* Успех: 200 {token, user: {id, name, email, role}}.
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$user = SalesUser::where('email', $request->email)->first();
|
||||
|
||||
if (! $user || ! Hash::check($request->password, $user->password)) {
|
||||
return response()->json(
|
||||
['message' => 'Неверный логин или пароль.'],
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
if (! $user->is_active) {
|
||||
return response()->json(
|
||||
['message' => 'Аккаунт отключён, обратитесь к начальнику.'],
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
$token = $user->createToken('sales')->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'token' => $token,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущий авторизованный менеджер.
|
||||
*
|
||||
* Guard: auth:sales — Sanctum Bearer-токен.
|
||||
* Возвращает: {id, name, email, role}.
|
||||
*/
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
/** @var SalesUser $user */
|
||||
$user = $request->user('sales');
|
||||
|
||||
return response()->json([
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход — инвалидирует текущий токен.
|
||||
*
|
||||
* Guard: auth:sales.
|
||||
* Возвращает: 200 {message}.
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
/** @var SalesUser $user */
|
||||
$user = $request->user('sales');
|
||||
$user->currentAccessToken()->delete();
|
||||
|
||||
return response()->json(['message' => 'Вы вышли.']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?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 — список (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)
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 1.3, Task 1.4)
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка клиента.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Support\SupplierProjectName;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -87,7 +88,7 @@ class DealsController extends Controller
|
||||
'contact_name' => $d->contact_name,
|
||||
'city' => $d->city,
|
||||
'status' => $d->status,
|
||||
'project' => $d->project?->name,
|
||||
'project' => SupplierProjectName::strip($d->project?->name),
|
||||
])->all(),
|
||||
'next_cursor' => $next,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use App\Models\SalesClientAssignment;
|
||||
use App\Models\SalesUser;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Ограничение ownership для портала отдела продаж.
|
||||
*
|
||||
* Менеджер видит только клиентов, закреплённых за ним (tenant_ids из
|
||||
* sales_client_assignments). Начальник (role='head') видит всех — null
|
||||
* означает «без ограничения».
|
||||
*
|
||||
* Используется в контроллерах /api/sales/* для автоматической фильтрации
|
||||
* данных в зависимости от роли авторизованного пользователя.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
|
||||
*/
|
||||
trait ScopesSalesOwnership
|
||||
{
|
||||
/**
|
||||
* null => начальник (видит всех); массив => менеджер (только эти tenant_id).
|
||||
*
|
||||
* @return list<int>|null
|
||||
*/
|
||||
protected function ownedTenantIds(SalesUser $user): ?array
|
||||
{
|
||||
if ($user->isHead()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var list<int> $ids */
|
||||
$ids = SalesClientAssignment::query()
|
||||
->where('sales_user_id', $user->id)
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет фильтр владения к Eloquent-запросу.
|
||||
*
|
||||
* Для начальника — возвращает запрос без изменений (видит всё).
|
||||
* Для менеджера — добавляет whereIn по $column.
|
||||
* Если у менеджера нет закреплённых клиентов — подставляет [-1],
|
||||
* чтобы запрос вернул пустую коллекцию.
|
||||
*
|
||||
* @template TModel of \Illuminate\Database\Eloquent\Model
|
||||
*
|
||||
* @param Builder<TModel> $query
|
||||
* @return Builder<TModel>
|
||||
*/
|
||||
protected function scopeByOwnership(Builder $query, SalesUser $user, string $column = 'tenant_id'): Builder
|
||||
{
|
||||
$ids = $this->ownedTenantIds($user);
|
||||
|
||||
if ($ids === null) {
|
||||
return $query; // начальник — без ограничения
|
||||
}
|
||||
|
||||
return $query->whereIn($column, $ids === [] ? [-1] : $ids); // пустой → ничего
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Гейт для зоны /api/sales/*.
|
||||
*
|
||||
* Проверяет, что входящий запрос аутентифицирован через guard «sales»
|
||||
* (Sanctum, провайдер sales_users) и что аккаунт активен (is_active=true).
|
||||
*
|
||||
* Применяется через псевдоним 'sales-portal', зарегистрированный в
|
||||
* bootstrap/app.php.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
|
||||
*/
|
||||
class EnsureSalesUser
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user('sales');
|
||||
|
||||
if ($user === null || ! $user->is_active) {
|
||||
abort(401, 'Требуется вход в портал отдела продаж.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,6 @@ class ProjectResource extends JsonResource
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
|
||||
'balance_blocked' => $this->preflight_blocked_at !== null,
|
||||
// Task 15: сырое поле — UI отличает «заблокирован при попытке запуска» vs «закончился баланс».
|
||||
'preflight_blocked_at' => $this->preflight_blocked_at?->toIso8601String(),
|
||||
'supplier_links' => $this->when(
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
|
||||
@@ -11,8 +11,8 @@ use App\Services\External\CaptchaLivenessProbe;
|
||||
use App\Services\External\DadataBalanceProvider;
|
||||
use App\Services\External\JivoLivenessProbe;
|
||||
use App\Services\External\LivenessProbe;
|
||||
use App\Services\External\SmtpLivenessProbe;
|
||||
use App\Services\External\SupplierBalanceProvider;
|
||||
use App\Services\External\Yandex360BalanceProvider;
|
||||
use App\Services\External\YandexCloudBalanceProvider;
|
||||
use App\Services\External\YooKassaLivenessProbe;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -44,7 +44,6 @@ class RefreshExternalBalancesJob implements ShouldQueue
|
||||
DadataBalanceProvider::class,
|
||||
SupplierBalanceProvider::class,
|
||||
YandexCloudBalanceProvider::class,
|
||||
Yandex360BalanceProvider::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -70,6 +69,7 @@ class RefreshExternalBalancesJob implements ShouldQueue
|
||||
}
|
||||
|
||||
return [
|
||||
app(SmtpLivenessProbe::class),
|
||||
app(YooKassaLivenessProbe::class),
|
||||
app(JivoLivenessProbe::class),
|
||||
app(CaptchaLivenessProbe::class),
|
||||
@@ -189,10 +189,6 @@ class RefreshExternalBalancesJob implements ShouldQueue
|
||||
(float) config('services.supplier.red_floor_rub', 5000),
|
||||
(float) config('services.supplier.amber_floor_rub', 15000),
|
||||
],
|
||||
'email' => [
|
||||
(float) config('services.yandex360.red_floor_rub', 500),
|
||||
(float) config('services.yandex360.amber_floor_rub', 2000),
|
||||
],
|
||||
default => [0.0, 0.0],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -394,12 +394,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
// Throws propagate to handle() catch (failover-counter); rows persisted for earlier
|
||||
// platforms before a throw are recovered next run via the missing-set recovery below.
|
||||
foreach ($platforms as $platform) {
|
||||
// Iterate only platforms with a ≥1 share ($shares omits 0-share — cabinet rejects limit=0).
|
||||
foreach (array_keys($shares) as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
limit: $shares[$platform],
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -420,7 +421,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => $shares[$platform],
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -454,11 +455,15 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
foreach ($deadSps as $sp) {
|
||||
// Пропускаем площадку, у которой теперь доля 0 (кабинет отклонит limit=0).
|
||||
if (! isset($shares[$sp->platform])) {
|
||||
continue;
|
||||
}
|
||||
$recreateDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
limit: $shares[$sp->platform],
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -486,7 +491,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
|
||||
// (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
// Только площадки с долей ≥1 ($shares уже без 0-долей).
|
||||
$missingPlatforms = array_values(array_diff(array_keys($shares), $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
@@ -494,7 +500,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
limit: $shares[$platform],
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -514,7 +520,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => $shares[$platform],
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -537,11 +543,16 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
// Площадка потеряла долю (лимит группы упал) → не шлём update с limit=0
|
||||
// (кабинет отклонит «Введите limit!»). Оставляем как есть до следующего пересчёта.
|
||||
if (! isset($shares[$sp->platform])) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$sp->platform] ?? 0,
|
||||
limit: $shares[$sp->platform],
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
@@ -551,7 +562,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
);
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $shares[$sp->platform] ?? 0,
|
||||
'current_limit' => $shares[$sp->platform],
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
|
||||
@@ -224,11 +224,12 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
$createResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
// Только площадки с долей ≥1 ($shares без 0-долей — кабинет отклоняет limit=0).
|
||||
$createResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, array_keys($shares));
|
||||
$idMap = $createResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $createResult['failed']);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
foreach (array_keys($shares) as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
@@ -240,7 +241,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => $shares[$platform],
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -266,7 +267,8 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
);
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
// Пересоздаём только площадки с долей ≥1 (0-долю кабинет отклоняет).
|
||||
$deadPlatforms = array_values(array_intersect($deadSps->pluck('platform')->all(), array_keys($shares)));
|
||||
$deadResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
$recreatedIdMap = $deadResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $deadResult['failed']);
|
||||
@@ -281,7 +283,8 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
// Partial-set recovery: если предыдущий run создал не все platforms.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
// Только площадки с долей ≥1 ($shares без 0-долей).
|
||||
$missingPlatforms = array_values(array_diff(array_keys($shares), $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
@@ -299,7 +302,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $shares[$platform] ?? 0,
|
||||
'current_limit' => $shares[$platform],
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $allRegions,
|
||||
'sync_status' => 'ok',
|
||||
@@ -314,6 +317,11 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
// Активная группа, но у этой площадки доля упала до 0 → не шлём update с limit=0
|
||||
// (кабинет отклонит «Введите limit!»). Оставляем как есть до следующего пересчёта.
|
||||
if ($groupActive && ! isset($shares[$sp->platform])) {
|
||||
continue;
|
||||
}
|
||||
// Portal requires a non-zero `limit` even when status=paused — sending 0
|
||||
// returns "Введите limit!". When the whole group is paused, keep the previous
|
||||
// current_limit so the portal accepts the update; status=paused stops orders.
|
||||
@@ -507,7 +515,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
platform: $platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: $shares[$platform] ?? 0,
|
||||
limit: $shares[$platform],
|
||||
workdays: $workdays,
|
||||
regions: $allRegions,
|
||||
regionsReverse: false,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Заявка менеджера на привязку клиента к своему профилю.
|
||||
*
|
||||
* SaaS-level модель: без RLS.
|
||||
*
|
||||
* status: 'pending' | 'approved' | 'rejected' | 'not_found'
|
||||
* tenant_id nullable — заполняется после поиска клиента по login_input.
|
||||
* decided_by — ссылка на sales_users.id (руководитель, принявший решение).
|
||||
*
|
||||
* Timestamps: только created_at (нет updated_at).
|
||||
*
|
||||
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $sales_user_id
|
||||
* @property string $login_input
|
||||
* @property int|null $tenant_id
|
||||
* @property string $status
|
||||
* @property string|null $comment
|
||||
* @property int|null $decided_by
|
||||
* @property Carbon|null $decided_at
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class SalesAttachmentRequest extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'sales_user_id',
|
||||
'login_input',
|
||||
'tenant_id',
|
||||
'status',
|
||||
'comment',
|
||||
'decided_by',
|
||||
'decided_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'decided_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesUser, $this> */
|
||||
public function salesUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesUser::class, 'sales_user_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesUser, $this> */
|
||||
public function decider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesUser::class, 'decided_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Привязка «один менеджер — один клиент» с snapshot тарифа.
|
||||
*
|
||||
* SaaS-level модель: без RLS. tenant_id UNIQUE → один клиент всегда
|
||||
* принадлежит не более чем одному менеджеру.
|
||||
*
|
||||
* tariff_params — snapshot параметров тарифа на момент привязки
|
||||
* (копируется из SalesTariff.params, не следует live изменениям тарифа).
|
||||
*
|
||||
* Timestamps: только created_at (нет updated_at — без timestamps = false,
|
||||
* задаём через $timestamps).
|
||||
*
|
||||
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $sales_user_id
|
||||
* @property int $tenant_id
|
||||
* @property int|null $tariff_id
|
||||
* @property string|null $tariff_kind
|
||||
* @property array<string,mixed> $tariff_params
|
||||
* @property Carbon $assigned_at
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class SalesClientAssignment extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'sales_user_id',
|
||||
'tenant_id',
|
||||
'tariff_id',
|
||||
'tariff_kind',
|
||||
'tariff_params',
|
||||
'assigned_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tariff_params' => 'array',
|
||||
'assigned_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesUser, $this> */
|
||||
public function salesUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesUser::class, 'sales_user_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesTariff, $this> */
|
||||
public function tariff(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTariff::class, 'tariff_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Append-only журнал выплат менеджерам портала отдела продаж.
|
||||
*
|
||||
* SaaS-level модель: без RLS.
|
||||
*
|
||||
* Append-only: UPDATE/DELETE запрещены DB-триггером sales_payouts_no_mutate()
|
||||
* (бросает EXCEPTION). Не добавляй update/delete логику в этот класс.
|
||||
*
|
||||
* Timestamps: только created_at (нет updated_at).
|
||||
*
|
||||
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $sales_user_id
|
||||
* @property string $amount_rub
|
||||
* @property Carbon $paid_on
|
||||
* @property string|null $comment
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
*/
|
||||
class SalesPayout extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'sales_user_id',
|
||||
'amount_rub',
|
||||
'paid_on',
|
||||
'comment',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount_rub' => 'decimal:2',
|
||||
'paid_on' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesUser, $this> */
|
||||
public function salesUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesUser::class, 'sales_user_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesUser, $this> */
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesUser::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Тарифный план портала отдела продаж.
|
||||
*
|
||||
* SaaS-level модель: без RLS. Используется как шаблон при привязке
|
||||
* менеджера к клиенту (snapshot копируется в SalesClientAssignment).
|
||||
*
|
||||
* kind: 'topup_step' | 'percent_oborot' | 'fix_per_client'
|
||||
* params: JSONB с параметрами тарифа (зависят от kind).
|
||||
*
|
||||
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $kind
|
||||
* @property array<string,mixed> $params
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesTariff extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'kind',
|
||||
'params',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'params' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return HasMany<SalesUser, $this> */
|
||||
public function salesUsers(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesUser::class, 'current_tariff_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<SalesClientAssignment, $this> */
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesClientAssignment::class, 'tariff_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* Аккаунт менеджера или руководителя портала отдела продаж.
|
||||
*
|
||||
* SaaS-level модель: без RLS. Отдельная таблица sales_users —
|
||||
* не путать с tenant-level users (User.php).
|
||||
*
|
||||
* role: 'manager' | 'head'
|
||||
*
|
||||
* Используется как Authenticatable для auth портала продаж;
|
||||
* HasApiTokens нужен для Sanctum API-токенов (будущие фазы).
|
||||
*
|
||||
* Источник: db/migrations/2026_07_01_100000_create_sales_portal_tables.php
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string $password
|
||||
* @property string $role
|
||||
* @property bool $is_active
|
||||
* @property string $base_salary_rub
|
||||
* @property int|null $current_tariff_id
|
||||
* @property int|null $created_by
|
||||
*/
|
||||
class SalesUser extends Authenticatable
|
||||
{
|
||||
use HasApiTokens;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'is_active',
|
||||
'base_salary_rub',
|
||||
'current_tariff_id',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'base_salary_rub' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Менеджер является руководителем (head) отдела продаж.
|
||||
*/
|
||||
public function isHead(): bool
|
||||
{
|
||||
return $this->role === 'head';
|
||||
}
|
||||
|
||||
/** @return BelongsTo<SalesTariff, $this> */
|
||||
public function currentTariff(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTariff::class, 'current_tariff_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<SalesClientAssignment, $this> */
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesClientAssignment::class, 'sales_user_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<SalesPayout, $this> */
|
||||
public function payouts(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesPayout::class, 'sales_user_id');
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
final readonly class GateResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $passes,
|
||||
public int $capacityLeads,
|
||||
public int $committedLeads,
|
||||
public int $requiredLeads,
|
||||
public int $deficitLeads,
|
||||
public string $topupRub,
|
||||
public string $balanceRub,
|
||||
) {}
|
||||
|
||||
/** @return array{current_balance_rub:string,current_capacity_leads:int,would_be_required_leads:int,deficit_leads:int,topup_rub:string} */
|
||||
public function toBalancePayload(): array
|
||||
{
|
||||
return [
|
||||
'current_balance_rub' => $this->balanceRub,
|
||||
'current_capacity_leads' => $this->capacityLeads,
|
||||
'would_be_required_leads' => $this->requiredLeads,
|
||||
'deficit_leads' => $this->deficitLeads,
|
||||
'topup_rub' => $this->topupRub,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
|
||||
final class LaunchBalanceGate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BalancePreflightService $preflight = new BalancePreflightService,
|
||||
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param int[] $excludeProjectIds проекты, чьи лимиты НЕ учитывать в committed (напр. сам активируемый)
|
||||
*/
|
||||
public function evaluate(Tenant $tenant, int $additionalLeads, array $excludeProjectIds = []): GateResult
|
||||
{
|
||||
$balanceRub = (string) $tenant->balance_rub;
|
||||
$deliveredInMonth = (int) $tenant->delivered_in_month;
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
$committed = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->when($excludeProjectIds !== [], fn ($q) => $q->whereNotIn('id', $excludeProjectIds))
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$required = $committed + max(0, $additionalLeads);
|
||||
|
||||
if ($tiers->isEmpty()) {
|
||||
$failClosed = (bool) config('billing.launch_requires_active_tiers', false);
|
||||
|
||||
return new GateResult(
|
||||
passes: ! $failClosed,
|
||||
capacityLeads: $failClosed ? 0 : PHP_INT_MAX,
|
||||
committedLeads: $committed,
|
||||
requiredLeads: $required,
|
||||
deficitLeads: $failClosed ? $required : 0,
|
||||
topupRub: '0.00',
|
||||
balanceRub: $balanceRub,
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->preflight->evaluate($balanceRub, $deliveredInMonth, $required, $tiers);
|
||||
|
||||
return new GateResult(
|
||||
passes: $result->passes,
|
||||
capacityLeads: $result->capacityLeads,
|
||||
committedLeads: $committed,
|
||||
requiredLeads: $required,
|
||||
deficitLeads: $result->deficitLeads,
|
||||
topupRub: $this->topupRub($balanceRub, $deliveredInMonth, $tiers, $result->deficitLeads),
|
||||
balanceRub: $balanceRub,
|
||||
);
|
||||
}
|
||||
|
||||
/** Сколько ₽ пополнить, чтобы дефицит-лиды поместились (по цене текущей ступени). */
|
||||
private function topupRub(string $balanceRub, int $deliveredInMonth, $tiers, int $deficitLeads): string
|
||||
{
|
||||
if ($deficitLeads <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
$priceRub = $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['current_tier']['price_rub'] ?? '0.00';
|
||||
|
||||
return bcmul($priceRub, (string) $deficitLeads, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
/**
|
||||
* Живость почты: TCP/TLS-connect к SMTP-порту Yandex 360 + чтение приветственного
|
||||
* баннера (должен начинаться с «220»). Без логина/отправки — денег/квоты не тратит.
|
||||
* Соединитель инъектируется (тестируемость): возвращает первую строку баннера или бросает.
|
||||
*/
|
||||
class SmtpLivenessProbe implements LivenessProbe
|
||||
{
|
||||
/** @var (callable():string)|null */
|
||||
private $connector;
|
||||
|
||||
/** @param (callable():string)|null $connector фейковый соединитель для тестов */
|
||||
public function __construct(?callable $connector = null)
|
||||
{
|
||||
$this->connector = $connector;
|
||||
}
|
||||
|
||||
public function serviceKey(): string
|
||||
{
|
||||
return 'email';
|
||||
}
|
||||
|
||||
public function check(): LivenessReading
|
||||
{
|
||||
try {
|
||||
$banner = ($this->connector ?? $this->defaultConnector())();
|
||||
if (! str_starts_with(ltrim($banner), '220')) {
|
||||
return LivenessReading::down('email', 'SMTP-баннер не 220: '.mb_substr(trim($banner), 0, 120));
|
||||
}
|
||||
|
||||
return LivenessReading::alive('email', 'SMTP отвечает');
|
||||
} catch (\Throwable $e) {
|
||||
return LivenessReading::down('email', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @return callable():string */
|
||||
private function defaultConnector(): callable
|
||||
{
|
||||
return function (): string {
|
||||
$host = (string) config('services.smtp_probe.host');
|
||||
$port = (int) config('services.smtp_probe.port');
|
||||
$timeout = (int) config('services.smtp_probe.timeout', 5);
|
||||
// 465 — implicit TLS; ssl:// нужен на connect.
|
||||
$scheme = $port === 465 ? 'ssl://' : 'tcp://';
|
||||
$fp = @stream_socket_client($scheme.$host.':'.$port, $errno, $errstr, $timeout);
|
||||
if ($fp === false) {
|
||||
throw new \RuntimeException($errstr !== '' ? $errstr : 'Connection refused (errno '.$errno.')');
|
||||
}
|
||||
try {
|
||||
stream_set_timeout($fp, $timeout);
|
||||
$line = fgets($fp, 512);
|
||||
|
||||
return $line === false ? '' : $line;
|
||||
} finally {
|
||||
fclose($fp);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
/**
|
||||
* Баланс Яндекс 360 (почта) — РУЧНОЙ ввод. У Яндекса нет API баланса, а робот-скрейпер
|
||||
* админки хрупкий (обфусцированный SPA) → владелец вписывает сумму вручную (решение 02.07);
|
||||
* баланс сам не кончится (в кабинете включено автопополнение). Ключ сервиса — email.
|
||||
* fetch() НЕ бросает: не задан → fail() (серый + подсказка ввести в админке).
|
||||
*/
|
||||
class Yandex360BalanceProvider implements BalanceProvider
|
||||
{
|
||||
public function __construct(private readonly Yandex360BalanceStore $store) {}
|
||||
|
||||
public function serviceKey(): string
|
||||
{
|
||||
return 'email';
|
||||
}
|
||||
|
||||
public function fetch(): BalanceReading
|
||||
{
|
||||
$balance = $this->store->get();
|
||||
if ($balance === null) {
|
||||
return BalanceReading::fail('email', 'Баланс почты не задан — введите вручную в админке (Система)');
|
||||
}
|
||||
|
||||
return BalanceReading::ok('email', $balance, 'RUB', null);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
|
||||
/**
|
||||
* Ручной баланс Яндекс 360 (почта): владелец периодически вписывает сумму из кабинета
|
||||
* (у Яндекса нет API баланса, а робот-скрейпер хрупкий — решение владельца 02.07).
|
||||
* Хранится числом в system_settings.yandex360_manual_balance (не секрет).
|
||||
*/
|
||||
class Yandex360BalanceStore
|
||||
{
|
||||
private const KEY = 'yandex360_manual_balance';
|
||||
|
||||
/** Сумма в рублях или null (не задана). */
|
||||
public function get(): ?float
|
||||
{
|
||||
$row = SystemSetting::find(self::KEY);
|
||||
if ($row === null || (string) $row->value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($row->value) ? (float) $row->value : null;
|
||||
}
|
||||
|
||||
/** Сохранить сумму. null или пусто → очистить. */
|
||||
public function set(?float $balance): void
|
||||
{
|
||||
$value = $balance === null ? '' : (string) $balance;
|
||||
SystemSetting::updateOrCreate(
|
||||
['key' => self::KEY],
|
||||
['value' => $value, 'type' => 'decimal', 'updated_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{balance:?float,updated_at:?string} */
|
||||
public function status(): array
|
||||
{
|
||||
$row = SystemSetting::find(self::KEY);
|
||||
$set = $row !== null && (string) $row->value !== '' && is_numeric($row->value);
|
||||
|
||||
return [
|
||||
'balance' => $set ? (float) $row->value : null,
|
||||
'updated_at' => $set && $row->updated_at ? $row->updated_at->toIso8601String() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Audit\OperationsLogger;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\LaunchBalanceGate;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
@@ -283,47 +282,6 @@ class ProjectService
|
||||
}
|
||||
}
|
||||
|
||||
public function setActive(Project $project, bool $active): Project
|
||||
{
|
||||
return DB::transaction(function () use ($project, $active) {
|
||||
Tenant::whereKey($project->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if (! $active) {
|
||||
$project->update(['is_active' => false, 'paused_at' => now()]);
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
$fresh = $project->fresh();
|
||||
$fresh->activate_deferred = false;
|
||||
$fresh->gate_payload = null;
|
||||
$fresh->syncOriginal();
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
$tenant = Tenant::findOrFail($project->tenant_id);
|
||||
$gate = app(LaunchBalanceGate::class)
|
||||
->evaluate($tenant, (int) $project->daily_limit_target, [$project->id]);
|
||||
|
||||
if (! $gate->passes) {
|
||||
$project->update(['preflight_blocked_at' => now()]); // остаётся на паузе
|
||||
$fresh = $project->fresh();
|
||||
$fresh->activate_deferred = true;
|
||||
$fresh->gate_payload = $gate->toBalancePayload();
|
||||
$fresh->syncOriginal();
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
$project->update(['is_active' => true, 'paused_at' => null, 'preflight_blocked_at' => null]);
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
$fresh = $project->fresh();
|
||||
$fresh->activate_deferred = false;
|
||||
$fresh->gate_payload = null;
|
||||
$fresh->syncOriginal();
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public function triggerSync(Project $project): void
|
||||
{
|
||||
// G (балансовый блок): ручная «Синхронизировать» не отправляет заблокированный проект.
|
||||
@@ -396,38 +354,25 @@ class ProjectService
|
||||
/**
|
||||
* Pause/resume + supplier sync per affected project (#10).
|
||||
*
|
||||
* Pause: mass-update (без гейта) + синк per id.
|
||||
* Resume: по одному через setActive (кумулятивный гейт «сколько влезло»).
|
||||
* paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
|
||||
* триггерит model events, поэтому для паузы пишем явно в одном UPDATE.
|
||||
* Without the dispatch, pause never reached the supplier (status stayed active).
|
||||
* The job's group recompute then pushes status=paused when no active project of
|
||||
* the group remains, or rebalances the order when some siblings are still active.
|
||||
*/
|
||||
private function bulkPauseResume($query, bool $isActive): array
|
||||
{
|
||||
if (! $isActive) {
|
||||
// Пауза — без гейта, как раньше (mass-update + синк per id).
|
||||
$ids = (clone $query)->pluck('id')->all();
|
||||
$updated = $query->update(['is_active' => false, 'paused_at' => DB::raw('NOW()')]);
|
||||
foreach ($ids as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
$ids = (clone $query)->pluck('id')->all();
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
|
||||
// триггерит model events, поэтому пишем явно в одном UPDATE.
|
||||
$updated = $query->update([
|
||||
'is_active' => $isActive,
|
||||
'paused_at' => $isActive ? null : DB::raw('NOW()'),
|
||||
]);
|
||||
foreach ($ids as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
|
||||
// Возобновление — «сколько влезло»: по одному через setActive (кумулятивный гейт).
|
||||
$projects = (clone $query)->get();
|
||||
$updated = 0;
|
||||
$skipped = [];
|
||||
foreach ($projects as $project) {
|
||||
$r = $this->setActive($project, true);
|
||||
if ($r->is_active) {
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped[] = ['id' => $project->id, 'reason' => 'balance_insufficient'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
|
||||
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
|
||||
}
|
||||
|
||||
private function bulkSimpleUpdate($query, array $update): array
|
||||
@@ -672,12 +617,13 @@ class ProjectService
|
||||
}
|
||||
}
|
||||
|
||||
public function create(Tenant $tenant, array $data, bool $launch = true): Project
|
||||
public function create(Tenant $tenant, array $data): Project
|
||||
{
|
||||
// Лимита по числу проектов нет — ограничение только по балансу/заказанным
|
||||
// лидам (балансовый гейт на запуске). Прежний гейт
|
||||
// лидам (балансовый префлайт в ProjectController::store). Прежний гейт
|
||||
// tenants.limits['max_projects'] убран как противоречащий правилу продукта.
|
||||
$data['tenant_id'] = $tenant->id;
|
||||
$data['is_active'] = true;
|
||||
$data['regions'] = $data['regions'] ?? [];
|
||||
// Plan 6 dual-write: regions[] источник истины; region_mask/mode — legacy для
|
||||
// PhonePrefixService / LeadRouter, удаляются в Plan 6.5 после переключения читателей.
|
||||
@@ -687,55 +633,29 @@ class ProjectService
|
||||
$this->assertNameUnique($tenant->id, (string) $data['name']);
|
||||
$this->assertSourceUnique($tenant->id, $data);
|
||||
|
||||
return DB::transaction(function () use ($tenant, $data, $launch) {
|
||||
/** @var Tenant $lockedTenant */
|
||||
$lockedTenant = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::create($data);
|
||||
|
||||
$gate = null;
|
||||
if ($launch) {
|
||||
$gate = app(LaunchBalanceGate::class)
|
||||
->evaluate($lockedTenant, (int) $data['daily_limit_target']);
|
||||
}
|
||||
$launched = $launch && ($gate !== null && $gate->passes);
|
||||
$this->ops->record(
|
||||
tenantId: $project->tenant_id,
|
||||
userId: auth()->id(),
|
||||
entityType: 'project',
|
||||
entityId: $project->id,
|
||||
event: 'project.created',
|
||||
payloadBefore: null,
|
||||
payloadAfter: $project->toArray(),
|
||||
ip: request()->ip(),
|
||||
userAgent: request()->userAgent(),
|
||||
);
|
||||
|
||||
$data['is_active'] = $launched;
|
||||
// Не запущен из-за баланса → durable-метка «удержан» (не заказываем).
|
||||
// Черновик (launch=false) → обычная пауза, без метки.
|
||||
$data['preflight_blocked_at'] = ($launch && ! $launched) ? now() : null;
|
||||
if (! $launched) {
|
||||
$data['paused_at'] = now();
|
||||
}
|
||||
// Заблокированный по балансу проект (preflight_blocked_at, Spec C §3.4) НЕ
|
||||
// заказываем у поставщика — зеркалит фильтр
|
||||
// BalancePreflightSweepJob::dispatchSupplierSyncIfOnline (->whereNull('preflight_blocked_at')).
|
||||
// Без гарда продавленный force_save_blocked-проект всё равно уезжал к поставщику
|
||||
// полным daily_limit_target, хотя лидов он не получает (слепок его исключает).
|
||||
if ($project->preflight_blocked_at === null) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
|
||||
$project = Project::create($data);
|
||||
|
||||
$this->ops->record(
|
||||
tenantId: $project->tenant_id,
|
||||
userId: auth()->id(),
|
||||
entityType: 'project',
|
||||
entityId: $project->id,
|
||||
event: 'project.created',
|
||||
payloadBefore: null,
|
||||
payloadAfter: $project->toArray(),
|
||||
ip: request()->ip(),
|
||||
userAgent: request()->userAgent(),
|
||||
);
|
||||
|
||||
// Заказ поставщику — только реально активный и не-удержанный проект.
|
||||
// Зеркалит фильтр BalancePreflightSweepJob::dispatchSupplierSyncIfOnline.
|
||||
if ($project->is_active && $project->preflight_blocked_at === null) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
|
||||
$fresh = $project->fresh();
|
||||
$fresh->launch_deferred = $launch && ! $launched;
|
||||
$fresh->gate_payload = ($launch && ! $launched && $gate !== null)
|
||||
? $gate->toBalancePayload()
|
||||
: null;
|
||||
// syncOriginal(): помечаем transient-поля как «оригинальные», чтобы Eloquent
|
||||
// не пытался их персистировать при последующем update() на том же объекте.
|
||||
$fresh->syncOriginal();
|
||||
|
||||
return $fresh;
|
||||
});
|
||||
return $project->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Метрики продаж для портала отдела продаж (Task 1.2).
|
||||
*
|
||||
* Читает существующие таблицы: deals, lead_charges, balance_transactions, tenants, projects.
|
||||
*
|
||||
* ВАЖНО — денежные правила:
|
||||
* - oborotRub: суммируем INTEGER kopecks (SUM(price_per_lead_kopecks)), делим на 100 в конце.
|
||||
* Float-суммирование ЗАПРЕЩЕНО (ведёт к накопительной ошибке при большом числе строк).
|
||||
* - topupsRub / cumulativeTopupsRub: DECIMAL(12,2) amount_rub — суммируем через SQL SUM,
|
||||
* возвращаем как float.
|
||||
*
|
||||
* ВАЖНО — граница периода (half-open interval):
|
||||
* - Запрос: >= range.start AND < nextDayAfterEnd (start-of-day AFTER last day).
|
||||
* - НЕ используем <= range.end (23:59:59 без микросекунд → теряем [23:59:59.001..полночь)).
|
||||
* - range.end → startOfDay()->addDay() = полночь следующего дня (UTC).
|
||||
*
|
||||
* Счётчик leadsDelivered соответствует DashboardController: deleted_at IS NULL, is_test=false.
|
||||
* Дубли (duplicate_of_id NOT NULL) НЕ исключаются — как в существующих "delivered" counts.
|
||||
*
|
||||
* runwayDays: реиспользует RunwayCalculator + BalanceToLeadsConverter — единый источник истины
|
||||
* для прогноза runway (совпадает с клиентским кабинетом и дашбордом, как требует RunwayCalculator
|
||||
* docblock F3 17.06.2026).
|
||||
*
|
||||
* Вызывается из /api/sales-зоны под middleware admin-db (pgsql_admin / crm_admin_user).
|
||||
* Сервис использует DEFAULT connection — не хардкодит имя подключения.
|
||||
*/
|
||||
class SalesMetricsService
|
||||
{
|
||||
/**
|
||||
* Число доставленных лидов тенанта за период.
|
||||
*
|
||||
* Определение «delivered»: deals с received_at в [range.start, nextDayAfterEnd),
|
||||
* is_test=false, deleted_at IS NULL. Дубли (duplicate_of_id NOT NULL) включаются —
|
||||
* исторически DashboardController их не исключает (поле duplicate_of_id не фильтруется).
|
||||
*/
|
||||
public function leadsDelivered(int $tenantId, SalesPeriodRange $range): int
|
||||
{
|
||||
$nextDay = $range->end->startOfDay()->addDay();
|
||||
|
||||
return (int) DB::table('deals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_test', false)
|
||||
->where('received_at', '>=', $range->start)
|
||||
->where('received_at', '<', $nextDay)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Оборот тенанта за период в рублях.
|
||||
*
|
||||
* SUM(price_per_lead_kopecks) по lead_charges в периоде, делённый на 100.
|
||||
* Суммируем INTEGER kopecks — не float, исключая накопительную ошибку.
|
||||
*/
|
||||
public function oborotRub(int $tenantId, SalesPeriodRange $range): float
|
||||
{
|
||||
$nextDay = $range->end->startOfDay()->addDay();
|
||||
|
||||
$sumKopecks = (int) DB::table('lead_charges')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('charged_at', '>=', $range->start)
|
||||
->where('charged_at', '<', $nextDay)
|
||||
->sum('price_per_lead_kopecks');
|
||||
|
||||
return $sumKopecks / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма пополнений тенанта за период в рублях.
|
||||
*
|
||||
* SUM(amount_rub) по balance_transactions где type='topup' в периоде.
|
||||
*/
|
||||
public function topupsRub(int $tenantId, SalesPeriodRange $range): float
|
||||
{
|
||||
$nextDay = $range->end->startOfDay()->addDay();
|
||||
|
||||
return (float) DB::table('balance_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'topup')
|
||||
->where('created_at', '>=', $range->start)
|
||||
->where('created_at', '<', $nextDay)
|
||||
->sum('amount_rub');
|
||||
}
|
||||
|
||||
/**
|
||||
* Накопленные пополнения тенанта за всё время (без ограничения периода).
|
||||
*
|
||||
* Используется для расчёта порога фиксированной выплаты.
|
||||
*/
|
||||
public function cumulativeTopupsRub(int $tenantId): float
|
||||
{
|
||||
return (float) DB::table('balance_transactions')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'topup')
|
||||
->sum('amount_rub');
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогноз «запас в днях» для тенанта.
|
||||
*
|
||||
* Реиспользует единственный источник истины: BalanceToLeadsConverter
|
||||
* (рублёвый баланс → число лидов по тарифной сетке) + RunwayCalculator
|
||||
* (лиды / дневной_заказ_активных_проектов).
|
||||
*
|
||||
* Результат совпадает с клиентским кабинетом (BillingController::wallet)
|
||||
* и дашбордом (DashboardController::summary) — F3 17.06.2026.
|
||||
*
|
||||
* null — нет активных проектов (нечего заказывать).
|
||||
* 0 — баланс исчерпан (affordable_leads = 0).
|
||||
* N — floor(affordable_leads / daily_order).
|
||||
*/
|
||||
public function runwayDays(int $tenantId): ?int
|
||||
{
|
||||
$activeTiers = app(PricingTierRepository::class)
|
||||
->activeAt(Carbon::now('Europe/Moscow'));
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$conversion = app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
(int) ($tenant->delivered_in_month ?? 0),
|
||||
$activeTiers,
|
||||
);
|
||||
|
||||
$affordableLeads = (int) $conversion['leads'];
|
||||
|
||||
return app(RunwayCalculator::class)->daysLeft($tenantId, $affordableLeads);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Конкретный диапазон дат для периода продаж.
|
||||
*
|
||||
* start — начало диапазона (00:00:00 МСК, включительно).
|
||||
* end — конец диапазона (23:59:59 МСК, включительно последнего дня).
|
||||
* Оба значения в часовом поясе Europe/Moscow.
|
||||
*/
|
||||
final readonly class SalesPeriodRange
|
||||
{
|
||||
public function __construct(
|
||||
public CarbonImmutable $start,
|
||||
public CarbonImmutable $end,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Преобразует выбор периода с фронтенда в конкретный диапазон дат МСК.
|
||||
*
|
||||
* Поддерживаемые kind:
|
||||
* 'this' — текущий месяц целиком.
|
||||
* 'prev' — предыдущий месяц целиком.
|
||||
* 'prev2' — месяц перед предыдущим целиком.
|
||||
* 'custom' — явный диапазон from..to (YYYY-MM-DD МСК).
|
||||
*
|
||||
* Неизвестный kind по умолчанию трактуется как 'this'.
|
||||
* Все вычисления — в Europe/Moscow через CarbonImmutable.
|
||||
* "Now" берётся из CarbonImmutable::now('Europe/Moscow'),
|
||||
* поэтому тесты могут замораживать время через CarbonImmutable::setTestNow().
|
||||
*/
|
||||
final class SalesPeriodResolver
|
||||
{
|
||||
private const TZ = 'Europe/Moscow';
|
||||
|
||||
/**
|
||||
* @param array{kind?: string, from?: string|null, to?: string|null} $period
|
||||
*
|
||||
* @throws InvalidArgumentException для kind=custom при неверных/отсутствующих датах
|
||||
*/
|
||||
public function resolve(array $period): SalesPeriodRange
|
||||
{
|
||||
$kind = $period['kind'] ?? 'this';
|
||||
|
||||
return match ($kind) {
|
||||
'prev' => $this->monthRange(-1),
|
||||
'prev2' => $this->monthRange(-2),
|
||||
'custom' => $this->customRange($period),
|
||||
default => $this->monthRange(0), // 'this' и любой неизвестный kind
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Список первых чисел каждого месяца (00:00 МСК), попадающего в диапазон.
|
||||
*
|
||||
* Например, диапазон 10 марта – 20 мая вернёт [1 марта, 1 апреля, 1 мая].
|
||||
*
|
||||
* @return list<CarbonImmutable>
|
||||
*/
|
||||
public function monthsIn(SalesPeriodRange $range): array
|
||||
{
|
||||
$months = [];
|
||||
$cursor = $range->start->startOfMonth();
|
||||
|
||||
while ($cursor->lte($range->end)) {
|
||||
$months[] = $cursor;
|
||||
$cursor = $cursor->addMonth();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
// ─── private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Полный диапазон месяца, смещённого на $offset от текущего.
|
||||
*
|
||||
* $offset = 0 → текущий месяц
|
||||
* $offset = -1 → предыдущий месяц
|
||||
* $offset = -2 → позапрошлый месяц
|
||||
*/
|
||||
private function monthRange(int $offset): SalesPeriodRange
|
||||
{
|
||||
$now = CarbonImmutable::now(self::TZ);
|
||||
|
||||
$base = $offset === 0
|
||||
? $now
|
||||
: $now->addMonths($offset);
|
||||
|
||||
$start = $base->startOfMonth()->startOfDay();
|
||||
$end = $base->endOfMonth()->setTime(23, 59, 59);
|
||||
|
||||
return new SalesPeriodRange($start, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{kind?: string, from?: string|null, to?: string|null} $period
|
||||
*/
|
||||
private function customRange(array $period): SalesPeriodRange
|
||||
{
|
||||
if (empty($period['from'])) {
|
||||
throw new InvalidArgumentException(
|
||||
'Для произвольного периода необходимо указать дату «от» (from).',
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($period['to'])) {
|
||||
throw new InvalidArgumentException(
|
||||
'Для произвольного периода необходимо указать дату «до» (to).',
|
||||
);
|
||||
}
|
||||
|
||||
$start = CarbonImmutable::parse($period['from'], self::TZ)->startOfDay();
|
||||
$end = CarbonImmutable::parse($period['to'], self::TZ)->setTime(23, 59, 59);
|
||||
|
||||
if ($start->gt($end)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Дата начала периода не может быть позже даты окончания.',
|
||||
);
|
||||
}
|
||||
|
||||
return new SalesPeriodRange($start, $end);
|
||||
}
|
||||
}
|
||||
@@ -107,8 +107,12 @@ final class SupplierQuotaAllocator
|
||||
* Портал НЕ делит — каждый B-проект набирает до своего лимита независимо; одинаковый
|
||||
* лимит на N площадках = заказ ×N (переплата). Verified live 2026-05-21.
|
||||
*
|
||||
* Площадки с долей 0 ОПУСКАЮТСЯ: новый кабинет crm.lead.store отклоняет `limit=0`
|
||||
* («Введите limit!», verified live 2026-07-01). Напр. заказ 1 на 3 площадки → только B1
|
||||
* получает 1, B2/B3 не отправляются. Сумма ненулевых долей по-прежнему == order.
|
||||
*
|
||||
* @param list<string> $platforms площадки в каноническом порядке (B1<B2<B3)
|
||||
* @return array<string, int> [platform => лимит этой площадки]
|
||||
* @return array<string, int> [platform => лимит этой площадки], только доли ≥ 1
|
||||
*/
|
||||
public static function distributeForPlatform(int $order, array $platforms): array
|
||||
{
|
||||
@@ -124,7 +128,10 @@ final class SupplierQuotaAllocator
|
||||
$shares = [];
|
||||
$i = 0;
|
||||
foreach ($platforms as $platform) {
|
||||
$shares[$platform] = $base + ($i < $remainder ? 1 : 0);
|
||||
$share = $base + ($i < $remainder ? 1 : 0);
|
||||
if ($share >= 1) {
|
||||
$shares[$platform] = $share;
|
||||
}
|
||||
$i++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Утилита отображения имён проектов поставщика — display-only.
|
||||
*
|
||||
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
|
||||
* Клиенту этот префикс показывать нельзя: он раскрывает нашу внутреннюю схему каналов и то,
|
||||
* что лиды перекупаются. Срезаем префикс во ВСЕХ клиентских ответах СЕРВЕРНО (API, экспорт),
|
||||
* а не только на фронте — иначе прямой API-потребитель и скачанный CSV/XLSX всё равно видят «B1_…».
|
||||
*
|
||||
* Серверный аналог resources/js/composables/projectName.ts::stripChannelPrefix.
|
||||
* Данные в БД (`supplier_projects.name` / `projects.name`) НЕ трогаем — только вывод.
|
||||
*/
|
||||
final class SupplierProjectName
|
||||
{
|
||||
/** Любой B + одна-или-более цифр + подчёркивание в начале (B1_/B6_/B8_/B10_…), но не буква (BX_). */
|
||||
private const CHANNEL_PREFIX_RE = '/^B\d+_/i';
|
||||
|
||||
/**
|
||||
* Срезает канальный префикс из начала имени проекта.
|
||||
* null → null (не ломаем nullable-контракт API), '' → '', остальное — без префикса.
|
||||
*/
|
||||
public static function strip(?string $name): ?string
|
||||
{
|
||||
if ($name === null || $name === '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return preg_replace(self::CHANNEL_PREFIX_RE, '', $name) ?? $name;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Http\Middleware\ApiKeyAuth;
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\EnsureSalesUser;
|
||||
use App\Http\Middleware\ImpersonationContext;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use App\Http\Middleware\UseAdminConnection;
|
||||
@@ -29,6 +30,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
'admin-db' => UseAdminConnection::class,
|
||||
'sales-portal' => EnsureSalesUser::class,
|
||||
'apikey' => ApiKeyAuth::class,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\SalesUser;
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
@@ -49,6 +50,13 @@ return [
|
||||
'impersonation' => [
|
||||
'driver' => 'impersonation',
|
||||
],
|
||||
|
||||
// Портал отдела продаж (Task 0.3). Sanctum Bearer-токены для sales_users.
|
||||
// Отдельный guard изолирует аккаунты менеджеров от tenant-users и saas-admins.
|
||||
'sales' => [
|
||||
'driver' => 'sanctum',
|
||||
'provider' => 'sales_users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -78,6 +86,12 @@ return [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
|
||||
// Провайдер для guard «sales» (портал отдела продаж, Task 0.3).
|
||||
'sales_users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => SalesUser::class,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Требовать активные pricing_tiers на СЕГОДНЯ для запуска проекта.
|
||||
// true (прод) → нет тарифа = запуск запрещён (fail-closed, чтобы не уехать
|
||||
// к поставщику при несконфигурированном биллинге). false (dev/тесты) →
|
||||
// сохраняем прежний safe-fallback «безлимит».
|
||||
'launch_requires_active_tiers' => (bool) env('BILLING_LAUNCH_REQUIRES_ACTIVE_TIERS', false),
|
||||
];
|
||||
@@ -95,6 +95,14 @@ return [
|
||||
'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000),
|
||||
],
|
||||
|
||||
// Healthcheck доступности SMTP (Yandex 360) для плитки внешних сервисов.
|
||||
// Только connect+баннер, без логина/отправки. Дефолты — под Yandex 360.
|
||||
'smtp_probe' => [
|
||||
'host' => env('SMTP_PROBE_HOST', env('MAIL_HOST', 'smtp.yandex.ru')),
|
||||
'port' => (int) env('SMTP_PROBE_PORT', 465),
|
||||
'timeout' => (int) env('SMTP_PROBE_TIMEOUT', 5),
|
||||
],
|
||||
|
||||
// G7-A: клиентская «Помощь».
|
||||
'support' => [
|
||||
'email' => env('SUPPORT_EMAIL', 'support@liderra.ru'),
|
||||
@@ -123,12 +131,4 @@ return [
|
||||
'alert_email' => env('MONITORING_ALERT_EMAIL', env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru')),
|
||||
],
|
||||
|
||||
// Баланс Яндекс 360 (почта) — РУЧНОЙ ввод (см. Yandex360BalanceProvider). Пороги
|
||||
// светофора — как у dadata; topup_url — страница «Оплата и тариф» в кабинете Яндекс 360.
|
||||
'yandex360' => [
|
||||
'red_floor_rub' => (int) env('YANDEX360_RED_FLOOR_RUB', 500),
|
||||
'amber_floor_rub' => (int) env('YANDEX360_AMBER_FLOOR_RUB', 2000),
|
||||
'topup_url' => env('YANDEX360_TOPUP_URL', 'https://admin.yandex.ru/products'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Портал отдела продаж — 5 системных таблиц (SaaS-level, без RLS).
|
||||
*
|
||||
* Таблицы:
|
||||
* - sales_tariffs — каталог тарифных экземпляров (топап/процент/фикс).
|
||||
* - sales_users — аккаунты менеджеров и руководителей отдела продаж.
|
||||
* - sales_client_assignments — привязка «один менеджер на клиента» (snapshot тарифа).
|
||||
* - sales_attachment_requests — заявки на привязку клиента.
|
||||
* - sales_payouts — append-only журнал выплат (UPDATE/DELETE запрещены триггером).
|
||||
*
|
||||
* Права: выданы crm_admin_user (admin-db connection, которым работает портал продаж).
|
||||
* Фильтрация по владельцу — в коде приложения, не в RLS.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md
|
||||
* План: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.1)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$db = DB::connection('pgsql_supplier');
|
||||
|
||||
// ── 1. sales_tariffs ────────────────────────────────────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sales_tariffs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL
|
||||
CHECK (kind IN ('topup_step', 'percent_oborot', 'fix_per_client')),
|
||||
params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── 2. sales_users ──────────────────────────────────────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sales_users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(10) NOT NULL DEFAULT 'manager'
|
||||
CHECK (role IN ('manager', 'head')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
base_salary_rub DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
current_tariff_id BIGINT REFERENCES sales_tariffs(id),
|
||||
created_by BIGINT REFERENCES sales_users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── 3. sales_client_assignments ─────────────────────────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sales_client_assignments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
|
||||
tenant_id BIGINT NOT NULL UNIQUE REFERENCES tenants(id),
|
||||
tariff_id BIGINT REFERENCES sales_tariffs(id),
|
||||
tariff_kind VARCHAR(20),
|
||||
tariff_params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE INDEX IF NOT EXISTS idx_sca_sales_user
|
||||
ON sales_client_assignments (sales_user_id)
|
||||
SQL);
|
||||
|
||||
// ── 4. sales_attachment_requests ────────────────────────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sales_attachment_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
|
||||
login_input VARCHAR(255) NOT NULL,
|
||||
tenant_id BIGINT REFERENCES tenants(id),
|
||||
status VARCHAR(12) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'not_found')),
|
||||
comment TEXT,
|
||||
decided_by BIGINT REFERENCES sales_users(id),
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_status
|
||||
ON sales_attachment_requests (status)
|
||||
SQL);
|
||||
|
||||
// ── 5. sales_payouts ────────────────────────────────────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS sales_payouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sales_user_id BIGINT NOT NULL REFERENCES sales_users(id),
|
||||
amount_rub DECIMAL(12,2) NOT NULL CHECK (amount_rub > 0),
|
||||
paid_on DATE NOT NULL,
|
||||
comment TEXT,
|
||||
created_by BIGINT NOT NULL REFERENCES sales_users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE INDEX IF NOT EXISTS idx_payout_user
|
||||
ON sales_payouts (sales_user_id)
|
||||
SQL);
|
||||
|
||||
// ── Append-only trigger для sales_payouts ───────────────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sales_payouts_no_mutate()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'sales_payouts is append-only';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
SQL);
|
||||
|
||||
$db->statement(<<<'SQL'
|
||||
CREATE OR REPLACE TRIGGER trg_sales_payouts_no_mutate
|
||||
BEFORE UPDATE OR DELETE ON sales_payouts
|
||||
FOR EACH ROW EXECUTE FUNCTION sales_payouts_no_mutate()
|
||||
SQL);
|
||||
|
||||
// ── GRANTs для crm_admin_user (idempotent DO-block) ─────────────────────
|
||||
$db->statement(<<<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE
|
||||
ON sales_tariffs, sales_users, sales_client_assignments,
|
||||
sales_attachment_requests
|
||||
TO crm_admin_user;
|
||||
|
||||
GRANT SELECT, INSERT
|
||||
ON sales_payouts
|
||||
TO crm_admin_user;
|
||||
|
||||
GRANT USAGE, SELECT
|
||||
ON ALL SEQUENCES IN SCHEMA public
|
||||
TO crm_admin_user;
|
||||
END IF;
|
||||
END
|
||||
$$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$db = DB::connection('pgsql_supplier');
|
||||
|
||||
$db->statement('DROP TABLE IF EXISTS sales_payouts CASCADE');
|
||||
$db->statement('DROP TABLE IF EXISTS sales_attachment_requests CASCADE');
|
||||
$db->statement('DROP TABLE IF EXISTS sales_client_assignments CASCADE');
|
||||
$db->statement('DROP TABLE IF EXISTS sales_users CASCADE');
|
||||
$db->statement('DROP TABLE IF EXISTS sales_tariffs CASCADE');
|
||||
$db->statement('DROP FUNCTION IF EXISTS sales_payouts_no_mutate()');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Таблица Sanctum personal_access_tokens — для Bearer-токенов портала продаж.
|
||||
*
|
||||
* Проект использует SPA cookie-auth для основного кабинета (таблица раньше не
|
||||
* создавалась), но портал отдела продаж (guard «sales») использует Sanctum
|
||||
* API-токены: SalesAuthController->createToken(...). Для них нужна эта таблица.
|
||||
*
|
||||
* DDL идёт через соединение pgsql_supplier (как остальные системные таблицы) —
|
||||
* на проде дефолтная роль crm_app_user не имеет CREATE. Гранты выданы
|
||||
* crm_admin_user: вся зона /api/sales проходит через admin-db (UseAdminConnection),
|
||||
* включая логин и проверку токена, поэтому Sanctum читает/пишет токены под
|
||||
* crm_admin_user. Миграция идемпотентна (Schema::hasTable + DO-блок грантов).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-sales-manager-portal-brainstorm.md
|
||||
* План: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.3)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection('pgsql_supplier');
|
||||
|
||||
if (! $schema->hasTable('personal_access_tokens')) {
|
||||
$schema->create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->text('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
// Гранты для crm_admin_user (admin-db connection, которым работает портал
|
||||
// продаж — включая логин/проверку токена). Идемпотентно, на dev no-op.
|
||||
DB::connection('pgsql_supplier')->statement(<<<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE
|
||||
ON personal_access_tokens
|
||||
TO crm_admin_user;
|
||||
GRANT USAGE, SELECT
|
||||
ON SEQUENCE personal_access_tokens_id_seq
|
||||
TO crm_admin_user;
|
||||
END IF;
|
||||
END
|
||||
$$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection('pgsql_supplier')->dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
@@ -73,13 +73,7 @@ parameters:
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/DealExportController.php
|
||||
|
||||
-
|
||||
message: '#^Strict comparison using \!\=\= between int and null will always evaluate to true\.$#'
|
||||
message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#'
|
||||
identifier: notIdentical.alwaysTrue
|
||||
count: 1
|
||||
path: app/Http/Middleware/SetTenantContext.php
|
||||
@@ -675,7 +669,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 14
|
||||
count: 15
|
||||
path: tests/Feature/Api/V1/PublicDealsApiTest.php
|
||||
|
||||
-
|
||||
@@ -1044,6 +1038,18 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1116,6 +1122,30 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BillingPreflightInitialSweepTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/ExpireInvoicesTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1473,7 +1503,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
count: 9
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
@@ -1491,7 +1521,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
count: 6
|
||||
path: tests/Feature/DealExportTest.php
|
||||
|
||||
-
|
||||
@@ -1527,7 +1557,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 45
|
||||
count: 47
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -1545,7 +1575,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 31
|
||||
count: 32
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -2730,6 +2760,78 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/SaasAdminMiddlewareTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Sales/SalesAuthTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Sales/SalesAuthTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Sales/SalesAuthTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Sales/SalesAuthTest.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
|
||||
|
||||
-
|
||||
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\<list\|null\>\:\:\$not\.$#'
|
||||
identifier: property.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: '#^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/SalesGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:withHeader\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Sales/SalesGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -3227,34 +3329,3 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/AdminInvoiceIndexTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/ExpireInvoicesTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/InvoiceCreateTest.php
|
||||
|
||||
@@ -312,24 +312,6 @@ export async function listSystemSettings(): Promise<SystemSetting[]> {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
export interface Yandex360BalanceStatus {
|
||||
balance: number | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** Ручной баланс Яндекс 360 (почта): текущее значение + когда обновляли. */
|
||||
export async function getYandex360Balance(): Promise<Yandex360BalanceStatus> {
|
||||
const { data } = await apiClient.get<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Сохранить ручной баланс Яндекс 360 (null — очистить). Возвращает новый статус. */
|
||||
export async function setYandex360Balance(balance: number | null): Promise<Yandex360BalanceStatus> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.put<Yandex360BalanceStatus>('/api/admin/settings/yandex360-balance', { balance });
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface UpdateSystemSettingPayload {
|
||||
value: string;
|
||||
reason: string; // ≥30 chars
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* API-клиент для портала отдела продаж (/api/sales/*).
|
||||
*
|
||||
* Использует Bearer-токен из salesAuth store (localStorage 'sales_token').
|
||||
* НЕ использует Sanctum cookie/CSRF — это отдельный auth через токен.
|
||||
*
|
||||
* Base path: /api/sales
|
||||
*/
|
||||
|
||||
export interface SalesUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'manager' | 'head';
|
||||
}
|
||||
|
||||
export interface SalesLoginResponse {
|
||||
token: string;
|
||||
user: SalesUser;
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem('sales_token');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает читаемое сообщение об ошибке из ответа API.
|
||||
*/
|
||||
export function extractSalesErrorMessage(error: unknown, fallback = 'Произошла ошибка. Попробуйте позже.'): string {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as { message?: string } | undefined;
|
||||
if (data?.message) return data.message;
|
||||
if (error.response?.status === 401) return 'Неверный email или пароль.';
|
||||
if (error.response?.status === 403) return 'Нет прав на это действие.';
|
||||
if (error.response?.status === 422) {
|
||||
const errData = error.response.data as { errors?: Record<string, string[]> } | undefined;
|
||||
const firstField = errData?.errors ? Object.values(errData.errors)[0] : undefined;
|
||||
if (firstField?.[0]) return firstField[0];
|
||||
}
|
||||
if (error.response?.status === 500) return 'Внутренняя ошибка сервера.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ─── types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SalesClientRow {
|
||||
tenant_id: number;
|
||||
organization_name: string;
|
||||
inn: string | null;
|
||||
subject_type: string | null;
|
||||
last_activity_at: string | null; // ISO datetime or null
|
||||
balance_rub: string;
|
||||
status: 'trial' | 'suspended' | 'overdue' | 'active' | string;
|
||||
tariff_name: string | null;
|
||||
projects_count: number;
|
||||
runway_days: number | null;
|
||||
leads_delivered: number;
|
||||
oborot_rub: number;
|
||||
earned_rub: null;
|
||||
}
|
||||
|
||||
export interface SalesClientsParams {
|
||||
period: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ─── auth endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/sales/auth/login → { token, user }
|
||||
*/
|
||||
export async function salesLogin(email: string, password: string): Promise<SalesLoginResponse> {
|
||||
const { data } = await axios.post<SalesLoginResponse>(
|
||||
'/api/sales/auth/login',
|
||||
{ email, password },
|
||||
{ headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sales/auth/me (Bearer) → { id, name, email, role }
|
||||
*/
|
||||
export async function salesMe(): Promise<SalesUser> {
|
||||
const { data } = await axios.get<SalesUser>('/api/sales/auth/me', {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...authHeaders(),
|
||||
},
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sales/auth/logout (Bearer)
|
||||
*/
|
||||
export async function salesLogout(): Promise<void> {
|
||||
await axios.post(
|
||||
'/api/sales/auth/logout',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...authHeaders(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── clients endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/sales/clients?period=...&from=...&to=...&search=... (Bearer)
|
||||
* → { data: SalesClientRow[] }
|
||||
*/
|
||||
export async function listSalesClients(params: SalesClientsParams): Promise<SalesClientRow[]> {
|
||||
const { data } = await axios.get<{ data: SalesClientRow[] }>('/api/sales/clients', {
|
||||
params,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...authHeaders(),
|
||||
},
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import AdminLayout from '../layouts/AdminLayout.vue';
|
||||
import AppLayout from '../layouts/AppLayout.vue';
|
||||
import AuthLayout from '../layouts/AuthLayout.vue';
|
||||
import PublicLayout from '../layouts/PublicLayout.vue';
|
||||
import SalesLayout from '../layouts/SalesLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const layoutName = computed(() => route.meta.layout ?? 'app');
|
||||
@@ -29,8 +30,10 @@ const DevIndexOverlay: Component | null = import.meta.env.DEV
|
||||
<template>
|
||||
<AuthLayout v-if="layoutName === 'auth'" />
|
||||
<RouterView v-else-if="layoutName === 'error'" />
|
||||
<RouterView v-else-if="layoutName === 'sales-login'" />
|
||||
<PublicLayout v-else-if="layoutName === 'public'" />
|
||||
<AdminLayout v-else-if="layoutName === 'admin'" />
|
||||
<SalesLayout v-else-if="layoutName === 'sales'" />
|
||||
<AppLayout v-else />
|
||||
<component :is="DevIndexOverlay" v-if="DevIndexOverlay" />
|
||||
</template>
|
||||
|
||||
@@ -102,19 +102,6 @@ async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
||||
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
|
||||
const balanceInsufficient = result.skipped.filter((s) => s.reason === 'balance_insufficient').length;
|
||||
const belowDelivered = result.skipped.filter((s) => s.reason === 'below_delivered_today').length;
|
||||
|
||||
// Для действия «Возобновить» с балансовым блоком — специальный тост со сводкой запуска.
|
||||
if (payload.action === 'resume' && balanceInsufficient > 0) {
|
||||
const parts: string[] = [`Запущено ${result.updated}, отложено ${balanceInsufficient} — не хватает баланса`];
|
||||
// Остальные причины добавляем следом, если есть.
|
||||
if (supplierLocked > 0) parts.push(`${supplierLocked} — мы уже начали сбор лидов на завтра`);
|
||||
if (withDeals > 0) parts.push(`${withDeals} — по проекту есть сделки`);
|
||||
if (belowDelivered > 0) parts.push(`${belowDelivered} — лимит ниже уже доставленных сегодня лидов`);
|
||||
skipToastText.value = parts.join('; ') + '.';
|
||||
skipToastOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const groups: string[] = [];
|
||||
if (supplierLocked > 0) {
|
||||
groups.push(
|
||||
|
||||
@@ -79,16 +79,7 @@
|
||||
</div>
|
||||
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
|
||||
|
||||
<v-tooltip v-if="project.preflight_blocked_at" location="top">
|
||||
<template #activator="{ props: tProps }">
|
||||
<v-chip :color="syncStatusColor" size="x-small" variant="tonal" v-bind="tProps" data-testid="balance-blocked-badge">
|
||||
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
||||
{{ syncStatusLabel }}
|
||||
</v-chip>
|
||||
</template>
|
||||
Пополните баланс, чтобы запустить проект
|
||||
</v-tooltip>
|
||||
<v-chip v-else :color="syncStatusColor" size="x-small" variant="tonal">
|
||||
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
|
||||
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
|
||||
{{ syncStatusLabel }}
|
||||
</v-chip>
|
||||
@@ -125,31 +116,25 @@ const progressPercent = computed(() => {
|
||||
return limit > 0 ? Math.min(100, Math.round((props.project.delivered_today / limit) * 100)) : 0;
|
||||
});
|
||||
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
|
||||
// H / Task 15: приоритет статусов:
|
||||
// 1. preflight_blocked_at != null → «Не запущен — не хватает баланса» (не запустился при попытке)
|
||||
// 2. balance_blocked → «Приостановлен — не хватает баланса» (закончился баланс в процессе)
|
||||
// 3. обычный sync_status
|
||||
const syncStatusLabel = computed(() => {
|
||||
if (props.project.preflight_blocked_at) {
|
||||
return 'Не запущен — не хватает баланса';
|
||||
}
|
||||
if (props.project.balance_blocked) {
|
||||
return 'Приостановлен — не хватает баланса';
|
||||
}
|
||||
return ({ ok: 'Собирает заявки', pending: 'Готовим к запуску', failed: 'Ошибка — напишите в поддержку' })[props.project.sync_status];
|
||||
});
|
||||
const syncStatusIcon = computed(() => {
|
||||
if (props.project.preflight_blocked_at || props.project.balance_blocked) {
|
||||
return 'mdi-cash-remove';
|
||||
}
|
||||
return ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[props.project.sync_status];
|
||||
});
|
||||
const syncStatusColor = computed(() => {
|
||||
if (props.project.preflight_blocked_at || props.project.balance_blocked) {
|
||||
return 'error';
|
||||
}
|
||||
return ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status];
|
||||
});
|
||||
// H (балансовый блок): заблокированный за нехваткой баланса проект имеет приоритет над
|
||||
// обычным sync-статусом — клиент должен сразу видеть причину «приостановки».
|
||||
const syncStatusLabel = computed(() =>
|
||||
props.project.balance_blocked
|
||||
? 'Приостановлен — не хватает баланса'
|
||||
: ({ ok: 'Собирает заявки', pending: 'Готовим к запуску', failed: 'Ошибка — напишите в поддержку' })[props.project.sync_status],
|
||||
);
|
||||
const syncStatusIcon = computed(() =>
|
||||
props.project.balance_blocked
|
||||
? 'mdi-cash-remove'
|
||||
: ({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
|
||||
props.project.sync_status
|
||||
],
|
||||
);
|
||||
const syncStatusColor = computed(() =>
|
||||
props.project.balance_blocked
|
||||
? 'error'
|
||||
: ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { Project, BalancePayload } from '../../stores/projectsStore';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null]; 'balance-insufficient': [balance: BalancePayload] }>();
|
||||
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -106,13 +106,7 @@ const sourceConfirmText = computed(() => {
|
||||
|
||||
async function onPause(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const result = await store.toggleActive(props.project);
|
||||
if (result?.deferred) {
|
||||
// Task 14: 409 balance_insufficient при попытке возобновить — не закрываем drawer,
|
||||
// сообщаем родителю показать диалог пополнения баланса.
|
||||
emit('balance-insufficient', result.balance);
|
||||
return;
|
||||
}
|
||||
await store.toggleActive(props.project);
|
||||
// #4: после паузы/возобновления панель и галочка должны исчезнуть (как у Save и Delete).
|
||||
emit('close');
|
||||
}
|
||||
@@ -228,19 +222,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
<aside class="project-details-drawer" :class="{ open: project !== null }">
|
||||
<div v-if="project" class="pdd-content">
|
||||
<header class="pdd-head">
|
||||
<div class="pdd-head-left">
|
||||
<div class="pdd-title">{{ project.name }}</div>
|
||||
<v-chip
|
||||
v-if="project.preflight_blocked_at"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
data-testid="drawer-balance-blocked-badge"
|
||||
>
|
||||
Не запущен — не хватает баланса
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="pdd-title">{{ project.name }}</div>
|
||||
<button class="pdd-close" data-testid="pdd-close" aria-label="Закрыть" @click="$emit('close')">
|
||||
<v-icon size="20" icon="mdi-close" />
|
||||
</button>
|
||||
@@ -454,13 +436,6 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--liderra-line, #e6e2d6);
|
||||
}
|
||||
.pdd-head-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.pdd-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Диалог «Проект не запущен» (Billing v2 Spec C §6.2, Task 12).
|
||||
* Диалог перегрузки лимита (Billing v2 Spec C §6.2, Task 1.10).
|
||||
*
|
||||
* Открывается, когда POST/PATCH /api/projects вернул 409 `balance_insufficient`.
|
||||
* Сообщает: «Проект создан, но НЕ запущен — не хватает баланса».
|
||||
* Предлагает три исхода:
|
||||
* - «Пополнить баланс» (data-test="topup") → закрыть + перейти в /billing;
|
||||
* - «Уменьшить объём» (data-test="reduce") → set-zero (родитель ставит daily_limit_target=0);
|
||||
* - «Понятно» (data-test="close") → закрыть без действий.
|
||||
*
|
||||
* Backward compat: события save-blocked и set-zero сохранены.
|
||||
* Показывает дефицит и предлагает три исхода:
|
||||
* - «Сохранить и приостановить» → save-blocked (родитель пере-сабмитит с
|
||||
* force_save_blocked=true → проект создаётся с preflight_blocked_at);
|
||||
* - «Поставить лимит 0» → set-zero (родитель ставит daily_limit_target=0);
|
||||
* - «Отмена» → закрытие без сохранения.
|
||||
*/
|
||||
export interface OverloadPayload {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
/** Task 12: сумма пополнения в рублях, рассчитанная бэкендом */
|
||||
topup_rub?: string;
|
||||
}
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -33,7 +29,7 @@ const emit = defineEmits<{
|
||||
'set-zero': [];
|
||||
}>();
|
||||
|
||||
// Косяк 06: «Пополнить» прямо из окна — закрываем и ведём в биллинг.
|
||||
// Косяк 06: «Пополнить» прямо из окна перегрузки — закрываем окно и ведём в биллинг.
|
||||
const router = useRouter();
|
||||
function goTopup(): void {
|
||||
emit('update:modelValue', false);
|
||||
@@ -44,31 +40,29 @@ function goTopup(): void {
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="520" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-card v-if="payload" data-testid="overload-dialog">
|
||||
<v-card-title>Проект не запущен</v-card-title>
|
||||
<v-card-title>Лимит превышает баланс</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
Проект создан, но <strong>не запущен</strong> — на балансе недостаточно средств для запуска.
|
||||
У вас {{ payload.current_balance_rub }}₽ = {{ payload.current_capacity_leads }} лидов по текущему
|
||||
тарифу.
|
||||
</p>
|
||||
<p>После сохранения нужно {{ payload.would_be_required_leads }} лидов.</p>
|
||||
<p class="font-weight-medium">Не хватает: {{ payload.deficit_leads }} лидов.</p>
|
||||
<p class="text-medium-emphasis mt-2">
|
||||
Чтобы запустить — пополните примерно на
|
||||
<strong>{{ payload.topup_rub ?? payload.deficit_leads }} ₽</strong>
|
||||
или уменьшите объём.
|
||||
</p>
|
||||
<p class="text-caption text-medium-emphasis mt-1">
|
||||
Текущий баланс: {{ payload.current_balance_rub }} ₽
|
||||
({{ payload.current_capacity_leads }} лидов), нужно {{ payload.would_be_required_leads }} лидов.
|
||||
Чтобы проект заработал — пополните счёт, поставьте его лимит 0 или уменьшите лимиты других проектов.
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions class="flex-wrap justify-end ga-2">
|
||||
<v-btn variant="text" data-test="close" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
|
||||
Понятно
|
||||
<v-btn variant="text" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
|
||||
Отмена
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-test="reduce" data-testid="overload-set-zero" @click="$emit('set-zero')">
|
||||
Уменьшить объём
|
||||
<v-btn variant="text" data-testid="overload-set-zero" @click="$emit('set-zero')">
|
||||
Поставить лимит 0
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" data-test="topup" data-testid="overload-topup" @click="goTopup">
|
||||
Пополнить баланс
|
||||
<v-btn variant="text" data-testid="overload-save-blocked" @click="$emit('save-blocked')">
|
||||
Сохранить и приостановить
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="flat" data-testid="overload-topup" @click="goTopup"> Пополнить баланс </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* HelpHint — знак вопроса «?» с тултипом.
|
||||
*
|
||||
* Источник дизайна: v8_sales.html .help "?" affordance.
|
||||
* Рендерит маленькую иконку «?» в кружке; при наведении показывает текст.
|
||||
* Используется внутри заголовков таблиц и KPI-плиток.
|
||||
*
|
||||
* Пример: <HelpHint text="На сколько дней хватит баланса при текущем расходе" />
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
/** Текст подсказки, который показывается в тултипе */
|
||||
text: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-tooltip :text="text" location="top" max-width="260">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span
|
||||
v-bind="tooltipProps"
|
||||
class="help-hint"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Подсказка: ${text}`"
|
||||
data-testid="help-hint"
|
||||
>?</span
|
||||
>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.help-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #f0ede4;
|
||||
border: 1px solid #d9d5cd;
|
||||
color: #66635c;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-left: 4px;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
font-family: var(--font-ui, 'Inter', system-ui, sans-serif);
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.help-hint:hover,
|
||||
.help-hint:focus-visible {
|
||||
background: #e1eeea;
|
||||
color: #084635;
|
||||
border-color: #b6d9cf;
|
||||
outline: 2px solid #0f6e56;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PeriodPicker — выбор периода для портала отдела продаж.
|
||||
*
|
||||
* Источник дизайна: v8_sales.html .fbtn select#period-sel + #custom-period.
|
||||
* При выборе «Произвольный» — показывает два date-поля (from / to).
|
||||
* Записывает состояние в salesPeriod store.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useSalesPeriodStore } from '../../stores/salesPeriod';
|
||||
import type { PeriodKind } from '../../stores/salesPeriod';
|
||||
|
||||
const period = useSalesPeriodStore();
|
||||
|
||||
interface PeriodOption {
|
||||
value: PeriodKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const options: PeriodOption[] = [
|
||||
{ value: 'this', label: 'Этот месяц' },
|
||||
{ value: 'prev', label: 'Прошлый месяц' },
|
||||
{ value: 'prev2', label: 'Позапрошлый месяц' },
|
||||
{ value: 'custom', label: 'Произвольный период…' },
|
||||
];
|
||||
|
||||
const selectedKind = computed({
|
||||
get: () => period.kind,
|
||||
set: (v: PeriodKind) => {
|
||||
if (v !== 'custom') {
|
||||
period.setPeriod({ kind: v });
|
||||
} else {
|
||||
period.setPeriod({ kind: 'custom', from: period.from, to: period.to });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const customFrom = computed({
|
||||
get: () => period.from ?? '',
|
||||
set: (v: string) => period.setPeriod({ kind: 'custom', from: v, to: period.to }),
|
||||
});
|
||||
|
||||
const customTo = computed({
|
||||
get: () => period.to ?? '',
|
||||
set: (v: string) => period.setPeriod({ kind: 'custom', from: period.from, to: v }),
|
||||
});
|
||||
|
||||
const isCustom = computed(() => period.kind === 'custom');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="period-picker" role="group" aria-label="Период данных">
|
||||
<v-select
|
||||
v-model="selectedKind"
|
||||
:items="options"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="period-select"
|
||||
aria-label="Выбор периода"
|
||||
data-testid="period-kind-select"
|
||||
/>
|
||||
<template v-if="isCustom">
|
||||
<v-text-field
|
||||
v-model="customFrom"
|
||||
type="date"
|
||||
label="С"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="period-date"
|
||||
data-testid="period-from"
|
||||
aria-label="Период с"
|
||||
/>
|
||||
<span class="period-dash">–</span>
|
||||
<v-text-field
|
||||
v-model="customTo"
|
||||
type="date"
|
||||
label="По"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="period-date"
|
||||
data-testid="period-to"
|
||||
aria-label="Период по"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.period-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.period-date {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.period-dash {
|
||||
color: #66635c;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,20 @@
|
||||
/**
|
||||
* Утилиты отображения имён проектов crm.bp.
|
||||
* Утилиты отображения имён проектов.
|
||||
*
|
||||
* Поставщик crm.bp префиксует имена проектов признаком канала-провайдера
|
||||
* (B1_/B2_/B3_ — три разных базы лидов). В UI Лидерры префикс — шум:
|
||||
* пользователю интересен сам проект, а не канал.
|
||||
* Внешний источник префиксует имена проектов кодом канала-провайдера
|
||||
* (B1_/B2_/B3_/B6_/B8_/B<N>_ — разные базы лидов). В UI Лидерры префикс — шум
|
||||
* и лишний намёк на внутреннюю кухню: пользователю интересен сам проект, а не канал.
|
||||
*
|
||||
* Трансформация — **display-only**: данные в БД (`supplier_projects.name`)
|
||||
* не трогаем, фильтрация/поиск/маппинг идёт по сырому имени и `id`.
|
||||
* Трансформация — **display-only**: данные в БД не трогаем,
|
||||
* фильтрация/поиск/маппинг идёт по сырому имени и `id`.
|
||||
* Серверный аналог для API/экспорта — App\Support\SupplierProjectName::strip().
|
||||
*/
|
||||
|
||||
const CHANNEL_PREFIX_RE = /^B[123]_/i;
|
||||
const CHANNEL_PREFIX_RE = /^B\d+_/i;
|
||||
|
||||
/**
|
||||
* Убирает префикс B1_/B2_/B3_ из начала имени проекта (case-insensitive).
|
||||
* Префикс внутри строки и другие буквы (B0/B4/Bx) не трогает.
|
||||
* Убирает канальный префикс B<цифры>_ из начала имени проекта (case-insensitive):
|
||||
* B1_/B2_/B3_/B6_/B8_/B10_… Букву (BX_) и префикс внутри строки не трогает.
|
||||
* null/undefined/'' -> ''.
|
||||
*/
|
||||
export function stripChannelPrefix(name: string | null | undefined): string {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Layout портала отдела продаж — sidebar #012019 с брендом «Лидерра / ОТДЕЛ ПРОДАЖ»,
|
||||
* двумя группами навигации (Менеджер / Начальник), topbar с PeriodPicker.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_sales.html #screen-portal.
|
||||
* Структурная модель: AdminLayout.vue.
|
||||
*
|
||||
* Секция «Начальник» отображается только при salesAuth.isHead === true.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { useSalesAuthStore } from '../stores/salesAuth';
|
||||
import PeriodPicker from '../components/sales/PeriodPicker.vue';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const managerNav: NavItem[] = [
|
||||
{ title: 'Сводка', icon: 'mdi-view-dashboard-outline', to: '/sales' },
|
||||
{ title: 'Мои клиенты', icon: 'mdi-account-group-outline', to: '/sales/clients' },
|
||||
{ title: 'Привязать клиента', icon: 'mdi-account-search-outline', to: '/sales/attach' },
|
||||
{ title: 'Мой доход', icon: 'mdi-currency-rub', to: '/sales/income' },
|
||||
];
|
||||
|
||||
const bossNav: NavItem[] = [
|
||||
{ title: 'Сводка отдела', icon: 'mdi-chart-line', to: '/sales/boss' },
|
||||
{ title: 'Результативность', icon: 'mdi-account-check-outline', to: '/sales/performance' },
|
||||
{ title: 'Тарифы менеджеров', icon: 'mdi-tag-arrow-right', to: '/sales/tariffs' },
|
||||
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/sales/invoices' },
|
||||
{ title: 'Заявки на привязку', icon: 'mdi-file-check-outline', to: '/sales/requests' },
|
||||
{ title: 'Выплаты', icon: 'mdi-credit-card-outline', to: '/sales/payouts' },
|
||||
{ title: 'Менеджеры', icon: 'mdi-account-multiple-outline', to: '/sales/managers' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const salesAuth = useSalesAuthStore();
|
||||
|
||||
const roleName = computed(() => (salesAuth.isHead ? 'Начальник' : 'Менеджер'));
|
||||
|
||||
const userInitials = computed(() => {
|
||||
const u = salesAuth.user;
|
||||
if (!u) return 'МП';
|
||||
const parts = u.name.split(' ').filter(Boolean);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
return u.name.slice(0, 2).toUpperCase();
|
||||
});
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
const all = [...managerNav, ...bossNav];
|
||||
return all.find((i) => route.path === i.to || route.path.startsWith(i.to + '/'))?.title ?? 'Продажи';
|
||||
});
|
||||
|
||||
function isActive(to: string): boolean {
|
||||
if (to === '/sales') return route.path === '/sales';
|
||||
return route.path.startsWith(to);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await salesAuth.logout();
|
||||
await router.push('/sales/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer color="#012019" theme="dark" :width="240" class="sales-drawer">
|
||||
<!-- Brand block -->
|
||||
<div class="brand-block">
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" width="22" height="22">
|
||||
<path
|
||||
d="M16 14 L16 34 L32 34"
|
||||
stroke="#012019"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-text">Лидерра<span class="brand-dot">.</span></span>
|
||||
</div>
|
||||
<div class="brand-sub">ОТДЕЛ ПРОДАЖ</div>
|
||||
|
||||
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Навигация отдела продаж">
|
||||
<!-- МЕНЕДЖЕР group -->
|
||||
<div class="nav-eyebrow">Менеджер</div>
|
||||
<v-list-item
|
||||
v-for="item in managerNav"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:active="isActive(item.to)"
|
||||
rounded="lg"
|
||||
class="nav-item"
|
||||
:exact="item.to === '/sales'"
|
||||
>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- НАЧАЛЬНИК group — only for head role -->
|
||||
<template v-if="salesAuth.isHead">
|
||||
<div class="nav-eyebrow nav-eyebrow--boss">Начальник</div>
|
||||
<v-list-item
|
||||
v-for="item in bossNav"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:active="isActive(item.to)"
|
||||
rounded="lg"
|
||||
class="nav-item"
|
||||
>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar :elevation="0" color="surface" :height="56" class="sales-topbar">
|
||||
<div class="crumb">
|
||||
<span class="text-medium-emphasis">Продажи</span>
|
||||
<v-icon size="14" class="mx-1">mdi-chevron-right</v-icon>
|
||||
<strong>{{ currentPageTitle }}</strong>
|
||||
</div>
|
||||
<v-spacer />
|
||||
|
||||
<!-- Period picker -->
|
||||
<PeriodPicker class="mr-3" />
|
||||
|
||||
<!-- Role chip -->
|
||||
<v-chip
|
||||
:color="salesAuth.isHead ? '#7B4D00' : '#084635'"
|
||||
:style="{
|
||||
background: salesAuth.isHead ? '#FFF4DD' : '#E1EEEA',
|
||||
color: salesAuth.isHead ? '#7B4D00' : '#084635',
|
||||
}"
|
||||
size="small"
|
||||
class="role-chip mr-2"
|
||||
label
|
||||
>
|
||||
{{ roleName.toUpperCase() }}
|
||||
</v-chip>
|
||||
|
||||
<!-- User menu -->
|
||||
<v-menu offset="8">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
class="user-chip mr-2"
|
||||
aria-label="Меню пользователя"
|
||||
>
|
||||
<v-avatar size="24" color="#0F6E56" class="mr-2">
|
||||
<span class="text-caption" style="color: #fff; font-size: 10px">{{ userInitials }}</span>
|
||||
</v-avatar>
|
||||
<span class="text-body-2">{{ salesAuth.user?.name ?? '' }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" min-width="200">
|
||||
<v-list-item v-if="salesAuth.user" :title="salesAuth.user.email" disabled />
|
||||
<v-divider v-if="salesAuth.user" />
|
||||
<v-list-item prepend-icon="mdi-logout" title="Выйти" @click="handleLogout" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main class="sales-main">
|
||||
<RouterView />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sales-drawer {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px 4px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
color: #32c8a9;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
color: #32c8a9;
|
||||
padding: 0 20px 14px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.nav-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
padding: 14px 16px 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-eyebrow--boss {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sales-topbar {
|
||||
border-bottom: 1px solid #d9d5cd !important;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.role-chip {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px !important;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
text-transform: none;
|
||||
border-color: #d9d5cd !important;
|
||||
}
|
||||
|
||||
.sales-main {
|
||||
background: #f6f3ec;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useSalesAuthStore } from '../stores/salesAuth';
|
||||
|
||||
/**
|
||||
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
|
||||
@@ -26,6 +27,10 @@ declare module 'vue-router' {
|
||||
devIndex?: number;
|
||||
devLabel?: string;
|
||||
transition?: string;
|
||||
/** Портал продаж: требует salesAuth.token */
|
||||
salesAuth?: boolean;
|
||||
/** Портал продаж: только для начальника (role==='head') */
|
||||
salesBossOnly?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +207,13 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/admin/dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('../views/admin/AdminDashboardView.vue'),
|
||||
meta: { layout: 'admin', title: 'Командный центр', requiresAuth: true, devIndex: 20, devLabel: 'Admin Dashboard' },
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Командный центр',
|
||||
requiresAuth: true,
|
||||
devIndex: 20,
|
||||
devLabel: 'Admin Dashboard',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/tenants',
|
||||
@@ -343,6 +354,103 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Помощь',
|
||||
},
|
||||
},
|
||||
// ─── Портал отдела продаж (/sales) ───────────────────────────────────────
|
||||
// Три группы:
|
||||
// 1. /sales/login — страница входа (без guard'а).
|
||||
// 2. Маршруты менеджера — требуют salesAuth.token.
|
||||
// 3. Маршруты начальника — требуют salesAuth.role === 'head'.
|
||||
// Guard реализован в beforeEach ниже через meta.salesAuth / meta.salesBossOnly.
|
||||
{
|
||||
path: '/sales/login',
|
||||
name: 'sales-login',
|
||||
component: () => import('../views/sales/SalesLoginView.vue'),
|
||||
meta: { layout: 'sales-login', title: 'Вход — Отдел продаж' },
|
||||
},
|
||||
{
|
||||
path: '/sales',
|
||||
name: 'sales-overview',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Сводка', salesAuth: true },
|
||||
props: { title: 'Сводка' },
|
||||
},
|
||||
{
|
||||
path: '/sales/clients',
|
||||
name: 'sales-clients',
|
||||
component: () => import('../views/sales/SalesClientsView.vue'),
|
||||
meta: { layout: 'sales', title: 'Мои клиенты', salesAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/sales/clients/:id',
|
||||
name: 'sales-client-detail',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Карточка клиента', salesAuth: true },
|
||||
props: { title: 'Карточка клиента' },
|
||||
},
|
||||
{
|
||||
path: '/sales/attach',
|
||||
name: 'sales-attach',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Привязать клиента', salesAuth: true },
|
||||
props: { title: 'Привязать клиента' },
|
||||
},
|
||||
{
|
||||
path: '/sales/income',
|
||||
name: 'sales-income',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Мой доход', salesAuth: true },
|
||||
props: { title: 'Мой доход' },
|
||||
},
|
||||
// Маршруты начальника (boss-only)
|
||||
{
|
||||
path: '/sales/boss',
|
||||
name: 'sales-boss',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Сводка отдела', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Сводка отдела' },
|
||||
},
|
||||
{
|
||||
path: '/sales/performance',
|
||||
name: 'sales-performance',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Результативность', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Результативность' },
|
||||
},
|
||||
{
|
||||
path: '/sales/tariffs',
|
||||
name: 'sales-tariffs',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Тарифы менеджеров', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Тарифы менеджеров' },
|
||||
},
|
||||
{
|
||||
path: '/sales/invoices',
|
||||
name: 'sales-invoices',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Счета', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Счета (оплата)' },
|
||||
},
|
||||
{
|
||||
path: '/sales/requests',
|
||||
name: 'sales-requests',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Заявки на привязку', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Заявки на привязку' },
|
||||
},
|
||||
{
|
||||
path: '/sales/payouts',
|
||||
name: 'sales-payouts',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Выплаты', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Выплаты менеджерам' },
|
||||
},
|
||||
{
|
||||
path: '/sales/managers',
|
||||
name: 'sales-managers',
|
||||
component: () => import('../views/sales/SalesStubView.vue'),
|
||||
meta: { layout: 'sales', title: 'Менеджеры', salesAuth: true, salesBossOnly: true },
|
||||
props: { title: 'Менеджеры' },
|
||||
},
|
||||
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
path: '/403',
|
||||
@@ -393,5 +501,25 @@ router.beforeEach(async (to) => {
|
||||
return { path: '/dashboard' };
|
||||
}
|
||||
|
||||
// ─── Guard для портала отдела продаж ─────────────────────────────────────
|
||||
if (to.meta.salesAuth) {
|
||||
const salesAuth = useSalesAuthStore();
|
||||
|
||||
// Cold start: если токен есть в localStorage, но user не загружен — грузим.
|
||||
if (salesAuth.token && !salesAuth.user) {
|
||||
await salesAuth.fetchMe();
|
||||
}
|
||||
|
||||
// Нет токена / нет user → /sales/login
|
||||
if (!salesAuth.isAuthenticated) {
|
||||
return { path: '/sales/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
// Boss-only маршрут: только начальник
|
||||
if (to.meta.salesBossOnly && !salesAuth.isHead) {
|
||||
return { path: '/sales' };
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -21,10 +21,6 @@ export interface Project {
|
||||
last_synced_at?: string | null;
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (preflight_blocked_at).
|
||||
balance_blocked?: boolean;
|
||||
// Task 15: сырое поле preflight_blocked_at для различения «заблокирован при попытке запуска»
|
||||
// vs «баланс закончился во время работы» (balance_blocked). Если не null — проект попытался
|
||||
// запуститься, но баланса не хватило.
|
||||
preflight_blocked_at?: string | null;
|
||||
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
|
||||
source_locked?: boolean;
|
||||
source_unlock_at?: string | null;
|
||||
@@ -33,15 +29,6 @@ export interface Project {
|
||||
source_change_message?: string | null;
|
||||
}
|
||||
|
||||
// Task 14: payload, возвращаемый бэкендом при 409 balance_insufficient на toggle-active.
|
||||
export interface BalancePayload {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
topup_rub: string;
|
||||
}
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
const items = ref<Project[]>([]);
|
||||
const total = ref(0);
|
||||
@@ -118,18 +105,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
await fetch();
|
||||
}
|
||||
|
||||
async function toggleActive(project: Project): Promise<{ deferred: true; balance: BalancePayload } | void> {
|
||||
try {
|
||||
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
|
||||
await fetch();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { error?: string; balance?: BalancePayload } } };
|
||||
if (err.response?.status === 409 && err.response?.data?.error === 'balance_insufficient') {
|
||||
// НЕ меняем is_active в сторе. Возвращаем payload для показа диалога.
|
||||
return { deferred: true, balance: err.response.data.balance! };
|
||||
}
|
||||
throw e; // другие ошибки — пробрасываем как обычно
|
||||
}
|
||||
async function toggleActive(project: Project) {
|
||||
await axios.patch(`/api/projects/${project.id}/toggle-active`, { is_active: !project.is_active });
|
||||
await fetch();
|
||||
}
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as salesApi from '../api/sales';
|
||||
import type { SalesUser } from '../api/sales';
|
||||
|
||||
/**
|
||||
* Auth-store для портала отдела продаж.
|
||||
*
|
||||
* Хранит Bearer-токен в localStorage ('sales_token') — в отличие от
|
||||
* основного auth (Sanctum SPA cookie), это token-based auth.
|
||||
*
|
||||
* Использование:
|
||||
* const salesAuth = useSalesAuthStore();
|
||||
* await salesAuth.login(email, password);
|
||||
* if (salesAuth.isAuthenticated) { ... }
|
||||
* if (salesAuth.isHead) { // только начальник }
|
||||
* await salesAuth.logout();
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'sales_token';
|
||||
|
||||
export const useSalesAuthStore = defineStore('salesAuth', () => {
|
||||
// Восстанавливаем токен из localStorage при старте.
|
||||
const token = ref<string | null>(
|
||||
(() => {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
const user = ref<SalesUser | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
// ─── getters ────────────────────────────────────────────────────────────
|
||||
|
||||
const isAuthenticated = computed(() => token.value !== null && user.value !== null);
|
||||
|
||||
const role = computed(() => user.value?.role ?? null);
|
||||
|
||||
const isHead = computed(() => user.value?.role === 'head');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function persistToken(t: string | null): void {
|
||||
try {
|
||||
if (t) {
|
||||
localStorage.setItem(TOKEN_KEY, t);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
} catch {
|
||||
// silent — localStorage может быть недоступен
|
||||
}
|
||||
token.value = t;
|
||||
}
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Войти в портал продаж.
|
||||
* Сохраняет токен + устанавливает user из ответа.
|
||||
*/
|
||||
async function login(email: string, password: string): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await salesApi.salesLogin(email, password);
|
||||
persistToken(response.token);
|
||||
user.value = response.user;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстановить сессию при cold start (если токен есть в localStorage).
|
||||
* Возвращает user или null (без throw при 401).
|
||||
*/
|
||||
async function fetchMe(): Promise<SalesUser | null> {
|
||||
if (!token.value) return null;
|
||||
try {
|
||||
const fetched = await salesApi.salesMe();
|
||||
user.value = fetched;
|
||||
return fetched;
|
||||
} catch {
|
||||
// 401 → токен устарел, очищаем
|
||||
persistToken(null);
|
||||
user.value = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выйти. Токен удаляется локально в любом случае.
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await salesApi.salesLogout();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
persistToken(null);
|
||||
user.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
role,
|
||||
isHead,
|
||||
login,
|
||||
fetchMe,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Store периода для портала отдела продаж.
|
||||
*
|
||||
* kind:
|
||||
* 'this' — текущий месяц
|
||||
* 'prev' — прошлый месяц
|
||||
* 'prev2' — позапрошлый месяц
|
||||
* 'custom' — произвольный период (from/to обязательны)
|
||||
*
|
||||
* Использование:
|
||||
* const period = useSalesPeriodStore();
|
||||
* period.setPeriod({ kind: 'prev' });
|
||||
* period.setPeriod({ kind: 'custom', from: '2026-05-01', to: '2026-05-31' });
|
||||
* const params = period.queryParams; // { period: 'prev' } или { period: 'custom', from, to }
|
||||
*/
|
||||
|
||||
export type PeriodKind = 'this' | 'prev' | 'prev2' | 'custom';
|
||||
|
||||
export interface PeriodState {
|
||||
kind: PeriodKind;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface PeriodQueryParams {
|
||||
period: PeriodKind;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export const useSalesPeriodStore = defineStore('salesPeriod', () => {
|
||||
const kind = ref<PeriodKind>('this');
|
||||
const from = ref<string | undefined>(undefined);
|
||||
const to = ref<string | undefined>(undefined);
|
||||
|
||||
// ─── getters ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Параметры для API-запросов с фильтрацией по периоду. */
|
||||
const queryParams = computed<PeriodQueryParams>(() => {
|
||||
if (kind.value === 'custom') {
|
||||
return { period: 'custom', from: from.value, to: to.value };
|
||||
}
|
||||
return { period: kind.value };
|
||||
});
|
||||
|
||||
/** Человекочитаемый ярлык текущего периода для UI. */
|
||||
const label = computed<string>(() => {
|
||||
const labels: Record<PeriodKind, string> = {
|
||||
this: 'Этот месяц',
|
||||
prev: 'Прошлый месяц',
|
||||
prev2: 'Позапрошлый месяц',
|
||||
custom: from.value && to.value ? `${from.value} — ${to.value}` : 'Произвольный период',
|
||||
};
|
||||
return labels[kind.value];
|
||||
});
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
function setPeriod(payload: PeriodState): void {
|
||||
kind.value = payload.kind;
|
||||
if (payload.kind === 'custom') {
|
||||
from.value = payload.from;
|
||||
to.value = payload.to;
|
||||
} else {
|
||||
from.value = undefined;
|
||||
to.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
from,
|
||||
to,
|
||||
queryParams,
|
||||
label,
|
||||
setPeriod,
|
||||
};
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Страница «Сделки» — реестр лидов, поставленных crm.bp (редизайн 2026-05-17).
|
||||
* Страница «Сделки» — реестр лидов от поставщика (редизайн 2026-05-17).
|
||||
*
|
||||
* Лиды поступают ТОЛЬКО от поставщика — ручного создания и корзины нет.
|
||||
* Фильтрация (телефон/Статус/Проект + диапазон дат поставки) и пагинация —
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
|
||||
* Импорт данных — загрузка CSV исторических лидов от поставщика (ТЗ §6).
|
||||
*
|
||||
* Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
|
||||
* Неизвестные статусы маппятся через UnknownStatusesDialog.
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
:selected="store.selectedIds.has(project.id)"
|
||||
@toggle-select="store.toggleSelect"
|
||||
@edit="openEdit"
|
||||
@toggle-active="onToggleActive"
|
||||
@toggle-active="store.toggleActive"
|
||||
@sync-now="(p: Project) => store.syncNow(p.id)"
|
||||
@delete="(p: Project) => store.del(p.id)"
|
||||
/>
|
||||
@@ -171,19 +171,9 @@
|
||||
Vuetify телепортируется в body и виден даже при скрытом баре. -->
|
||||
<BulkActionsBar v-show="store.selectedIds.size >= 2" />
|
||||
|
||||
<ProjectDetailsDrawer
|
||||
:project="singleSelectedProject"
|
||||
@close="onDrawerClose"
|
||||
@saved="onDrawerSaved"
|
||||
@balance-insufficient="onDrawerBalanceInsufficient"
|
||||
/>
|
||||
<ProjectDetailsDrawer :project="singleSelectedProject" @close="onDrawerClose" @saved="onDrawerSaved" />
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
|
||||
<ProjectLimitOverloadDialog
|
||||
v-model="balanceDialogOpen"
|
||||
:payload="balanceDialogPayload"
|
||||
@set-zero="balanceDialogOpen = false"
|
||||
/>
|
||||
|
||||
<v-snackbar
|
||||
v-model="savedSnackbarOpen"
|
||||
@@ -198,13 +188,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||||
import { useProjectsStore, type Project, type BalancePayload } from '../stores/projectsStore';
|
||||
import { useProjectsStore, type Project } from '../stores/projectsStore';
|
||||
import ProjectCard from '../components/projects/ProjectCard.vue';
|
||||
import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vue';
|
||||
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
||||
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
||||
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
||||
import ProjectLimitOverloadDialog from '../components/projects/ProjectLimitOverloadDialog.vue';
|
||||
import { REGIONS } from '../constants/regions';
|
||||
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
|
||||
|
||||
@@ -213,10 +202,6 @@ const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editing = ref<Project | null>(null);
|
||||
|
||||
// Task 14: диалог «не хватает баланса» при попытке возобновить проект.
|
||||
const balanceDialogOpen = ref(false);
|
||||
const balanceDialogPayload = ref<BalancePayload | null>(null);
|
||||
|
||||
// Тост «Сохранено» после правки проекта. Если правка задела slepok-чувствительные
|
||||
// поля (regions / delivery_days_mask / daily_limit_target / источник), backend
|
||||
// возвращает applies_from = N.21:00 МСК — показываем расширенное сообщение.
|
||||
@@ -253,21 +238,6 @@ const singleSelectedProject = computed<Project | null>(() => {
|
||||
return store.items.find((p: Project) => p.id === id) ?? null;
|
||||
});
|
||||
|
||||
// Task 14: обработчик toggle-active с перехватом 409 balance_insufficient.
|
||||
async function onToggleActive(project: Project): Promise<void> {
|
||||
const result = await store.toggleActive(project);
|
||||
if (result?.deferred) {
|
||||
balanceDialogPayload.value = result.balance;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Task 14: обработчик события balance-insufficient от ProjectDetailsDrawer.
|
||||
function onDrawerBalanceInsufficient(balance: BalancePayload): void {
|
||||
balanceDialogPayload.value = balance;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function onDrawerClose(): void {
|
||||
store.clearSelection();
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ function daysLeftLabel(days: number | null): string {
|
||||
}
|
||||
|
||||
/** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */
|
||||
const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'jivosite', 'captcha']);
|
||||
const LIVENESS_ONLY_KEYS = new Set(['email', 'yookassa', 'jivosite', 'captcha']);
|
||||
function isLivenessOnly(key: string): boolean {
|
||||
return LIVENESS_ONLY_KEYS.has(key);
|
||||
}
|
||||
|
||||
@@ -505,7 +505,7 @@ onMounted(() => {
|
||||
<v-dialog :model-value="confirmResolveId !== null" max-width="420" @update:model-value="confirmResolveId = null">
|
||||
<v-card class="pa-2">
|
||||
<v-card-title class="text-subtitle-1">Закрыть запись очереди?</v-card-title>
|
||||
<v-card-text>Подтверждаете, что внесли изменения в crm.bp-gr.ru?</v-card-text>
|
||||
<v-card-text>Подтверждаете, что внесли изменения в кабинете поставщика (crm.lead.store)?</v-card-text>
|
||||
<v-card-actions class="px-4 pb-3">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="confirmResolveId = null">Отмена</v-btn>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="admin-supplier-projects-view pa-6">
|
||||
<h1 class="text-h5 mb-4">Проекты у поставщика</h1>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект на портале и локальные привязки
|
||||
Все проекты, заведённые у поставщика (crm.lead.store). Удаление снимает проект на портале и локальные привязки
|
||||
тенантов (каскадом).
|
||||
</p>
|
||||
|
||||
|
||||
@@ -88,40 +88,7 @@ function onSettingUpdated(payload: { key: string; value: string; updated_at: str
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ручной баланс Яндекс 360 (почта) ───────────────────────────────────────
|
||||
const y360Input = ref<string>('');
|
||||
const y360Status = ref<adminApi.Yandex360BalanceStatus>({ balance: null, updated_at: null });
|
||||
const y360Saving = ref(false);
|
||||
const y360Error = ref<string | null>(null);
|
||||
const Y360_TOPUP_URL = 'https://admin.yandex.ru/products';
|
||||
|
||||
async function loadY360Status() {
|
||||
try {
|
||||
y360Status.value = await adminApi.getYandex360Balance();
|
||||
y360Input.value = y360Status.value.balance !== null ? String(y360Status.value.balance) : '';
|
||||
} catch {
|
||||
// тихо: статус не критичен для экрана
|
||||
}
|
||||
}
|
||||
|
||||
async function saveY360Balance() {
|
||||
y360Saving.value = true;
|
||||
y360Error.value = null;
|
||||
try {
|
||||
const val = y360Input.value.trim() === '' ? null : Number(y360Input.value.replace(',', '.'));
|
||||
y360Status.value = await adminApi.setYandex360Balance(val);
|
||||
} catch (err) {
|
||||
y360Error.value = extractErrorMessage(err, 'Не удалось сохранить баланс.');
|
||||
} finally {
|
||||
y360Saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadY360Status();
|
||||
});
|
||||
|
||||
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError, y360Input, y360Status, saveY360Balance });
|
||||
defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated, loadSettings, loading, fetchError });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,41 +105,6 @@ defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated,
|
||||
<code>saas_admin_audit_log</code> с hash-chain (OPEN-И-15).
|
||||
</v-alert>
|
||||
|
||||
<v-card variant="outlined" class="mb-4" data-testid="y360-balance-card">
|
||||
<v-card-title class="text-subtitle-1">✉️ Баланс почты (Яндекс 360)</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mb-3">
|
||||
У Яндекс 360 нет автоматического способа узнать баланс, поэтому впишите сумму вручную —
|
||||
она из кабинета Яндекс 360 → «Оплата и тариф» (строка «Баланс»). Обновляйте по мере надобности;
|
||||
на плитке она покажется со светофором. Кнопка «Открыть оплату» ведёт прямо в кабинет.
|
||||
</v-alert>
|
||||
<div class="mb-2 text-body-2">
|
||||
Текущий баланс:
|
||||
<strong v-if="y360Status.balance !== null" class="text-success">{{ y360Status.balance }} ₽</strong>
|
||||
<strong v-else class="text-medium-emphasis">не задан</strong>
|
||||
<span v-if="y360Status.updated_at" class="text-medium-emphasis">
|
||||
· обновлён {{ formatDate(y360Status.updated_at) }}</span>
|
||||
</div>
|
||||
<v-alert v-if="y360Error" type="warning" variant="tonal" density="compact" class="mb-2">
|
||||
{{ y360Error }}
|
||||
</v-alert>
|
||||
<div class="d-flex align-center ga-3 flex-wrap">
|
||||
<v-text-field
|
||||
v-model="y360Input"
|
||||
label="Баланс, ₽"
|
||||
type="number"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="max-width: 200px"
|
||||
/>
|
||||
<v-btn color="primary" :loading="y360Saving" @click="saveY360Balance">Сохранить</v-btn>
|
||||
<v-btn variant="text" :href="Y360_TOPUP_URL" target="_blank" prepend-icon="mdi-open-in-new">
|
||||
Открыть оплату
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
class="mb-4"
|
||||
data-testid="np-lead-banner"
|
||||
>
|
||||
📣 Если баланса хватает — проект запустится сразу. Иначе будет создан на паузе, и вы сможете пополнить баланс. Первые лиды пойдут с {{ leadStart }}.
|
||||
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
|
||||
@@ -276,7 +276,13 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-caption">Дни недели приёма</span>
|
||||
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
|
||||
<v-btn-toggle
|
||||
v-model="selectedDays"
|
||||
multiple
|
||||
density="comfortable"
|
||||
class="mt-1 day-toggle"
|
||||
selected-class="day-active"
|
||||
>
|
||||
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
<div class="mt-1">
|
||||
@@ -309,6 +315,7 @@
|
||||
<ProjectLimitOverloadDialog
|
||||
v-model="overloadOpen"
|
||||
:payload="overloadPayload"
|
||||
@save-blocked="onOverloadSaveBlocked"
|
||||
@set-zero="onOverloadSetZero"
|
||||
/>
|
||||
|
||||
@@ -435,9 +442,9 @@ const reqSaving = ref(false);
|
||||
const reqGeneralError = ref<string | null>(null);
|
||||
|
||||
const subjectTypeItems = [
|
||||
{ value: 'individual', title: 'Физлицо' },
|
||||
{ value: 'individual', title: 'Физическое лицо' },
|
||||
{ value: 'sole_proprietor', title: 'ИП' },
|
||||
{ value: 'legal_entity', title: 'Юрлицо' },
|
||||
{ value: 'legal_entity', title: 'Юридическое лицо' },
|
||||
];
|
||||
|
||||
// Зеркало RequisitesService::isLightComplete — тип лица + имя + телефон (+ ИНН для юр/ИП).
|
||||
@@ -613,16 +620,7 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
|
||||
appliesFrom = data?.data?.applies_from ?? null;
|
||||
} else {
|
||||
const { data } = await apiClient.post('/api/projects', body);
|
||||
// Create всегда 201. Если launch.deferred=1 — проект создан, но не запущен (баланс).
|
||||
if (data?.launch?.deferred) {
|
||||
// Проект создан — закрываем форму и показываем диалог перегрузки.
|
||||
emit('saved', null);
|
||||
close();
|
||||
overloadPayload.value = data.launch.balance ?? null;
|
||||
overloadOpen.value = true;
|
||||
return;
|
||||
}
|
||||
await apiClient.post('/api/projects', body);
|
||||
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
|
||||
}
|
||||
overloadOpen.value = false;
|
||||
@@ -638,11 +636,9 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
step.value = 'requisites';
|
||||
void initCreateStep();
|
||||
}
|
||||
// Spec C §6.2: лимит превышает баланс при UPDATE (edit-режим) — открываем диалог перегрузки.
|
||||
// Payload вложен в поле balance (новый контракт update-409).
|
||||
// CREATE не возвращает 409 по балансу — используется launch.deferred в success-ветке выше.
|
||||
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
|
||||
else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
|
||||
overloadPayload.value = (err.response.data?.balance ?? null) as OverloadPayloadShape | null;
|
||||
overloadPayload.value = err.response.data as OverloadPayloadShape;
|
||||
overloadOpen.value = true;
|
||||
} else if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
@@ -693,6 +689,10 @@ async function submit() {
|
||||
}
|
||||
|
||||
// Spec C §6.2 — исходы диалога перегрузки лимита.
|
||||
async function onOverloadSaveBlocked(): Promise<void> {
|
||||
await persist({ force_save_blocked: true });
|
||||
}
|
||||
|
||||
async function onOverloadSetZero(): Promise<void> {
|
||||
form.daily_limit_target = 0;
|
||||
overloadOpen.value = false;
|
||||
@@ -773,4 +773,12 @@ defineExpose({
|
||||
border-color: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
/* Выбранные дни недели — сплошная зелёная заливка, как в ProjectDetailsDrawer (.pdd-day.active) */
|
||||
.day-toggle :deep(.v-btn.day-active) {
|
||||
background-color: #0f6e56;
|
||||
color: #fff;
|
||||
}
|
||||
.day-toggle :deep(.v-btn.day-active .v-btn__overlay) {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Портал продаж → Мои клиенты (Task 1.3).
|
||||
*
|
||||
* Таблица клиентов менеджера/начальника. Реализует #page-clients из v8_sales.html.
|
||||
* Данные: GET /api/sales/clients?period=...&search=...
|
||||
* Период берётся из salesPeriod store (PeriodPicker уже встроен в SalesLayout topbar).
|
||||
* При смене периода таблица перегружается автоматически (watch queryParams).
|
||||
*/
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { listSalesClients, type SalesClientRow } from '../../api/sales';
|
||||
import { useSalesPeriodStore } from '../../stores/salesPeriod';
|
||||
import HelpHint from '../../components/sales/HelpHint.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const periodStore = useSalesPeriodStore();
|
||||
|
||||
const rows = ref<SalesClientRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
const search = ref('');
|
||||
|
||||
// ─── subject_type labels ──────────────────────────────────────────────────────
|
||||
|
||||
const SUBJECT_TYPE_LABELS: Record<string, string> = {
|
||||
individual: 'Физическое лицо',
|
||||
sole_proprietor: 'ИП',
|
||||
legal_entity: 'Юридическое лицо',
|
||||
};
|
||||
|
||||
function subjectLabel(type: string | null): string {
|
||||
if (!type) return '—';
|
||||
return SUBJECT_TYPE_LABELS[type] ?? type;
|
||||
}
|
||||
|
||||
// ─── status chips ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatusMeta {
|
||||
label: string;
|
||||
color: string;
|
||||
variant: 'tonal' | 'flat';
|
||||
}
|
||||
|
||||
const STATUS_META: Record<string, StatusMeta> = {
|
||||
trial: { label: 'Триал', color: 'blue-grey', variant: 'tonal' },
|
||||
active: { label: 'Активен', color: 'success', variant: 'tonal' },
|
||||
overdue: { label: 'Просрочка', color: 'error', variant: 'tonal' },
|
||||
suspended: { label: 'Приостановлен', color: 'grey', variant: 'tonal' },
|
||||
};
|
||||
|
||||
function statusMeta(s: string): StatusMeta {
|
||||
return STATUS_META[s] ?? { label: s, color: 'grey', variant: 'tonal' };
|
||||
}
|
||||
|
||||
// ─── formatters ───────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtMoney(val: string | number): string {
|
||||
const n = typeof val === 'string' ? parseFloat(val) : val;
|
||||
if (isNaN(n)) return '—';
|
||||
return n.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' ₽';
|
||||
}
|
||||
|
||||
function fmtRunway(days: number | null): string {
|
||||
if (days === null || days === undefined) return '—';
|
||||
return days + ' дн.';
|
||||
}
|
||||
|
||||
function fmtActivity(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
// Format: "28.06 09:41"
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'только что';
|
||||
if (diffMin < 60) return `${diffMin} мин назад`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `${diffH} ч назад`;
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
if (diffD < 7) return `${diffD} дн назад`;
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${dd}.${mm}`;
|
||||
}
|
||||
|
||||
// ─── data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const params = {
|
||||
...periodStore.queryParams,
|
||||
...(search.value ? { search: search.value } : {}),
|
||||
};
|
||||
rows.value = await listSalesClients(params);
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
void load();
|
||||
}
|
||||
|
||||
// Reload when period changes
|
||||
watch(() => periodStore.queryParams, load, { deep: true });
|
||||
|
||||
onMounted(load);
|
||||
|
||||
// ─── row click ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openClient(row: SalesClientRow) {
|
||||
router.push('/sales/clients/' + row.tenant_id);
|
||||
}
|
||||
|
||||
defineExpose({ rows, loading, fetchError, search, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="sales-clients pa-6">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap ga-3">
|
||||
<div>
|
||||
<h1 class="text-h5 font-weight-bold sc-page-title">Мои клиенты</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="d-flex align-center ga-3 mb-4 flex-wrap">
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
placeholder="Название клиента, ИНН…"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
style="max-width: 320px"
|
||||
data-testid="search-input"
|
||||
@keyup.enter="applySearch"
|
||||
/>
|
||||
<v-btn color="primary" class="text-none" data-testid="apply-search" @click="applySearch"> Найти </v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Error alert -->
|
||||
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" closable class="mb-4">
|
||||
Не удалось загрузить данные. Попробуйте обновить страницу.
|
||||
</v-alert>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
|
||||
|
||||
<!-- Table -->
|
||||
<v-card variant="outlined">
|
||||
<v-table density="compact" data-testid="clients-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Клиент</th>
|
||||
<th>
|
||||
Тип
|
||||
<HelpHint text="Тип лица клиента: физическое лицо, ИП или юридическое лицо" />
|
||||
</th>
|
||||
<th>Активность</th>
|
||||
<th class="text-right sc-num">Баланс</th>
|
||||
<th class="text-right sc-num">
|
||||
Запас
|
||||
<HelpHint text="На сколько дней хватит баланса при текущем заказе" />
|
||||
</th>
|
||||
<th class="text-right sc-num">Проектов</th>
|
||||
<th class="text-right sc-num">
|
||||
Пришло
|
||||
<HelpHint text="Сколько лидов доставлено клиенту за выбранный период" />
|
||||
</th>
|
||||
<th class="text-right sc-num">
|
||||
Оборот
|
||||
<HelpHint text="Сумма, на которую клиент получил лидов за период" />
|
||||
</th>
|
||||
<th>
|
||||
Тариф
|
||||
<HelpHint
|
||||
text="Тариф этого клиента. Закрепляется при привязке и не меняется, даже если ваш тариф потом изменят"
|
||||
/>
|
||||
</th>
|
||||
<th class="text-right sc-num">
|
||||
Заработал
|
||||
<HelpHint text="Ваша комиссия с этого клиента за период" />
|
||||
</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.tenant_id"
|
||||
class="sc-row"
|
||||
data-testid="client-row"
|
||||
@click="openClient(row)"
|
||||
>
|
||||
<!-- Клиент -->
|
||||
<td class="sc-name-cell">
|
||||
<span class="sc-org">{{ row.organization_name }}</span>
|
||||
<span v-if="row.inn" class="sc-inn">ИНН {{ row.inn }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Тип -->
|
||||
<td>
|
||||
<span v-if="row.subject_type" class="sc-type-badge">
|
||||
{{ subjectLabel(row.subject_type) }}
|
||||
</span>
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</td>
|
||||
|
||||
<!-- Активность -->
|
||||
<td class="text-medium-emphasis sc-activity">
|
||||
{{ fmtActivity(row.last_activity_at) }}
|
||||
</td>
|
||||
|
||||
<!-- Баланс -->
|
||||
<td class="text-right sc-num sc-mono">
|
||||
{{ fmtMoney(row.balance_rub) }}
|
||||
</td>
|
||||
|
||||
<!-- Запас -->
|
||||
<td class="text-right sc-num sc-mono">
|
||||
{{ fmtRunway(row.runway_days) }}
|
||||
</td>
|
||||
|
||||
<!-- Проектов -->
|
||||
<td class="text-right sc-num sc-mono">
|
||||
{{ row.projects_count }}
|
||||
</td>
|
||||
|
||||
<!-- Пришло -->
|
||||
<td class="text-right sc-num sc-mono">
|
||||
{{ row.leads_delivered }}
|
||||
</td>
|
||||
|
||||
<!-- Оборот -->
|
||||
<td class="text-right sc-num sc-mono">
|
||||
{{ fmtMoney(row.oborot_rub) }}
|
||||
</td>
|
||||
|
||||
<!-- Тариф -->
|
||||
<td>
|
||||
<span v-if="row.tariff_name" class="sc-tariff">{{ row.tariff_name }}</span>
|
||||
<span v-else class="text-medium-emphasis">—</span>
|
||||
</td>
|
||||
|
||||
<!-- Заработал — Phase 3, always «—» for now -->
|
||||
<td class="text-right sc-num text-medium-emphasis" data-testid="earned-cell">—</td>
|
||||
|
||||
<!-- Статус -->
|
||||
<td>
|
||||
<v-chip
|
||||
:color="statusMeta(row.status).color"
|
||||
:variant="statusMeta(row.status).variant"
|
||||
size="x-small"
|
||||
data-testid="status-chip"
|
||||
>
|
||||
{{ statusMeta(row.status).label }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty state -->
|
||||
<tr v-if="rows.length === 0 && !loading">
|
||||
<td colspan="11" class="text-center text-medium-emphasis pa-6">Клиенты не найдены</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sales-clients {
|
||||
max-width: 1500px;
|
||||
}
|
||||
|
||||
.sc-page-title {
|
||||
color: #081319;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sc-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sc-row:hover td {
|
||||
background: rgba(15, 110, 86, 0.05);
|
||||
}
|
||||
|
||||
.sc-name-cell {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.sc-org {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sc-inn {
|
||||
display: block;
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
color: #66635c;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.sc-type-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: #e1eeea;
|
||||
color: #084635;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sc-activity {
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sc-num {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sc-mono {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sc-tariff {
|
||||
font-size: 12px;
|
||||
color: #343c41;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Экран входа в портал отдела продаж.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_sales.html #screen-login.
|
||||
* Двухколоночный split: левая — брендовая плашка (#012019), правая — форма.
|
||||
*
|
||||
* Auth: email + password → POST /api/sales/auth/login → токен в salesAuth store.
|
||||
* После успешного входа — redirect /sales.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSalesAuthStore } from '../../stores/salesAuth';
|
||||
import { extractSalesErrorMessage } from '../../api/sales';
|
||||
|
||||
const router = useRouter();
|
||||
const salesAuth = useSalesAuthStore();
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
const showPassword = ref(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage.value = null;
|
||||
loading.value = true;
|
||||
try {
|
||||
await salesAuth.login(email.value, password.value);
|
||||
await router.push('/sales');
|
||||
} catch (err: unknown) {
|
||||
errorMessage.value = extractSalesErrorMessage(err, 'Неверный email или пароль. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sso-shell">
|
||||
<!-- Левая колонка: бренд -->
|
||||
<aside class="sso-brand">
|
||||
<div class="sso-brand-head">
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" width="22" height="22">
|
||||
<path
|
||||
d="M16 14 L16 34 L32 34"
|
||||
stroke="#012019"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-name">Лидерра<span class="brand-dot">.</span></span>
|
||||
<span class="brand-tag">ОТДЕЛ ПРОДАЖ</span>
|
||||
</div>
|
||||
<div class="sso-brand-body">
|
||||
Портал <em>менеджеров по продажам</em>.<br />
|
||||
Свои клиенты, их деньги и активность — в одном месте.
|
||||
</div>
|
||||
<div class="sso-brand-foot">v8 Forest · Лидерра CRM</div>
|
||||
</aside>
|
||||
|
||||
<!-- Правая колонка: форма -->
|
||||
<main class="sso-form">
|
||||
<div class="sso-card">
|
||||
<h1 class="sso-title">Вход</h1>
|
||||
<p class="sso-subtitle">Введите данные вашей учётной записи.</p>
|
||||
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
data-testid="login-error"
|
||||
rounded="lg"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<v-text-field
|
||||
v-model="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-3"
|
||||
:disabled="loading"
|
||||
data-testid="email-field"
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
label="Пароль"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-4"
|
||||
:disabled="loading"
|
||||
data-testid="password-field"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
required
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
/>
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="#0F6E56"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
data-testid="submit-btn"
|
||||
>
|
||||
Войти
|
||||
</v-btn>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sso-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sso-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sso-brand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Левая: брендовая плашка */
|
||||
.sso-brand {
|
||||
background: #012019;
|
||||
color: #ffffff;
|
||||
padding: 56px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sso-brand-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: #ffffff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
color: #32c8a9;
|
||||
}
|
||||
|
||||
.brand-tag {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
background: #0f6e56;
|
||||
color: #ffffff;
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sso-brand-body {
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
max-width: 440px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sso-brand-body em {
|
||||
color: #32c8a9;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.sso-brand-foot {
|
||||
font-size: 12px;
|
||||
color: #7a8c87;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Правая: форма */
|
||||
.sso-form {
|
||||
background: #f6f3ec;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.sso-card {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sso-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.2;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.sso-subtitle {
|
||||
font-size: 12.5px;
|
||||
color: #66635c;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Заглушка для экранов портала продаж, которые будут реализованы в следующих фазах.
|
||||
* Принимает необязательный prop `title` для отображения названия страницы.
|
||||
*/
|
||||
defineProps<{
|
||||
title?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-8">
|
||||
<div class="text-h5 font-weight-semibold mb-2" style="color: #081319">
|
||||
{{ title ?? 'Скоро' }}
|
||||
</div>
|
||||
<p style="color: #66635c; font-size: 14px">Этот раздел будет реализован в следующих фазах разработки.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +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).
|
||||
@@ -171,10 +173,6 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
|
||||
});
|
||||
|
||||
// SaaS-admin → Система: ручной баланс Яндекс 360 (почта).
|
||||
Route::get('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@show');
|
||||
Route::put('/api/admin/settings/yandex360-balance', 'App\Http\Controllers\Api\AdminYandex360BalanceController@update');
|
||||
|
||||
// SaaS-admin → Биллинг: ввод секретных ключей платёжного шлюза (config зашифрован).
|
||||
Route::put('/api/admin/payment-gateways/{code}', 'App\Http\Controllers\Api\AdminPaymentGatewayController@update')
|
||||
->where('code', '[a-z0-9_]+');
|
||||
@@ -231,6 +229,24 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
|
||||
// Всё через admin-db (crm_admin_user): и логин, и проверка токена, и cross-tenant
|
||||
// чтение; каждый запрос данных фильтруется по владению (ScopesSalesOwnership).
|
||||
// admin-db СТОИТ ПЕРЕД auth:sales (Sanctum читает токены/sales_users под crm_admin_user).
|
||||
Route::middleware('admin-db')->prefix('api/sales/auth')->group(function () {
|
||||
Route::post('/login', [SalesAuthController::class, 'login']);
|
||||
Route::middleware('auth:sales')->group(function () {
|
||||
Route::get('/me', [SalesAuthController::class, 'me']);
|
||||
Route::post('/logout', [SalesAuthController::class, 'logout']);
|
||||
});
|
||||
});
|
||||
// Зона данных портала (наполняется в Фазах 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
|
||||
});
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
|
||||
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\External\Yandex360BalanceStore;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::table('system_settings')->where('key', 'yandex360_manual_balance')->delete();
|
||||
});
|
||||
|
||||
it('GET отдаёт balance/updated_at (пусто по умолчанию)', function () {
|
||||
$this->getJson('/api/admin/settings/yandex360-balance')
|
||||
->assertOk()
|
||||
->assertJson(['balance' => null, 'updated_at' => null]);
|
||||
});
|
||||
|
||||
it('PUT сохраняет баланс', function () {
|
||||
$this->putJson('/api/admin/settings/yandex360-balance', ['balance' => 1464.31])
|
||||
->assertOk()
|
||||
->assertJson(['balance' => 1464.31]);
|
||||
expect(app(Yandex360BalanceStore::class)->get())->toBe(1464.31);
|
||||
});
|
||||
|
||||
it('PUT с пустым значением очищает', function () {
|
||||
app(Yandex360BalanceStore::class)->set(500.0);
|
||||
$this->putJson('/api/admin/settings/yandex360-balance', ['balance' => ''])->assertOk();
|
||||
expect(app(Yandex360BalanceStore::class)->get())->toBeNull();
|
||||
});
|
||||
|
||||
it('PUT отвергает отрицательный баланс', function () {
|
||||
$this->putJson('/api/admin/settings/yandex360-balance', ['balance' => -5])
|
||||
->assertStatus(422);
|
||||
});
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
@@ -48,6 +50,22 @@ test('валидный ключ → 200 и только свои сделки',
|
||||
expect($r->json('data'))->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('project в ответе без канального префикса B<N>_ (не палим поставщика)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'name' => 'B6_okna.ru']);
|
||||
Deal::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'project_id' => $project->id, 'received_at' => now(),
|
||||
]);
|
||||
|
||||
$key = makeApiKey($tenant->id, $user->id);
|
||||
$r = $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"]);
|
||||
|
||||
$r->assertOk();
|
||||
expect($r->json('data.0.project'))->toBe('okna.ru');
|
||||
});
|
||||
|
||||
test('нет заголовка → 401', function () {
|
||||
$this->getJson('/api/v1/deals')->assertStatus(401);
|
||||
});
|
||||
|
||||
@@ -100,3 +100,17 @@ test('POST /api/deals/export нейтрализует CSV-формулы в св
|
||||
expect($body)->not->toContain('"=HYPERLINK(');
|
||||
expect($body)->not->toContain(';@SUM');
|
||||
});
|
||||
|
||||
test('POST /api/deals/export срезает канальный префикс B<N>_ из источника (не палим поставщика)', function () {
|
||||
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru [12]']);
|
||||
Deal::factory()->for($this->tenant)->for($bgProject)->create([
|
||||
'phone' => '+7 999 333-33-33', 'received_at' => '2026-05-15 10:00:00',
|
||||
]);
|
||||
|
||||
$body = $this->post('/api/deals/export', [
|
||||
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
|
||||
])->streamedContent();
|
||||
|
||||
expect($body)->toContain('okna.ru [12]');
|
||||
expect($body)->not->toContain('B6_');
|
||||
});
|
||||
|
||||
@@ -392,3 +392,13 @@ test('GET /api/deals cost_kopecks = null если списания нет', func
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deals.0.cost_kopecks'))->toBeNull();
|
||||
});
|
||||
|
||||
test('GET /api/deals срезает канальный префикс B<N>_ из project_name (не палим поставщика)', function () {
|
||||
$bgProject = Project::factory()->for($this->tenant)->create(['name' => 'B6_okna.ru']);
|
||||
Deal::factory()->for($this->tenant)->for($bgProject)->create(['status' => 'new']);
|
||||
|
||||
$r = $this->getJson('/api/deals');
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deals.0.project_name'))->toBe('okna.ru');
|
||||
});
|
||||
|
||||
@@ -7,24 +7,13 @@ use App\Services\External\BalanceReading;
|
||||
use App\Services\External\DadataBalanceProvider;
|
||||
use App\Services\External\LivenessReading;
|
||||
use App\Services\External\SupplierBalanceProvider;
|
||||
use App\Services\External\Yandex360BalanceProvider;
|
||||
use App\Services\External\YandexCloudBalanceProvider;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class); // запись идёт через pgsql_supplier
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake(); // этот файл про запись балансов, не про письма (алерт — в ExternalServiceDownAlertTest)
|
||||
// email — денежный сервис (Yandex 360). Дефолтный стаб (зелёный, выше порогов); тесты могут переопределить.
|
||||
app()->instance(
|
||||
Yandex360BalanceProvider::class,
|
||||
fakeProvider('email', BalanceReading::ok('email', 5000, 'RUB', null)),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(fn () => RefreshExternalBalancesJob::resetLivenessProbes());
|
||||
|
||||
// Стабы fakeProvider()/fakeProbe() — глобальные хелперы в tests/Pest.php.
|
||||
@@ -41,7 +30,7 @@ it('пишет балансы трёх сервисов + считает све
|
||||
(new RefreshExternalBalancesJob)->handle();
|
||||
|
||||
$rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get()->keyBy('service_key');
|
||||
expect($rows)->toHaveCount(4); // dadata/supplier/yandex_cloud + email (денежный)
|
||||
expect($rows)->toHaveCount(3);
|
||||
expect((float) $rows['yandex_cloud']->balance_amount)->toBe(-540.48);
|
||||
expect($rows['yandex_cloud']->light)->toBe('red'); // минус < red_floor
|
||||
expect((bool) $rows['yandex_cloud']->ok)->toBeTrue();
|
||||
@@ -58,7 +47,7 @@ it('повторный запуск обновляет строки, а не п
|
||||
(new RefreshExternalBalancesJob)->handle(); // второй прогон не должен бросить UniqueConstraint
|
||||
|
||||
$rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get();
|
||||
expect($rows)->toHaveCount(4); // dadata/supplier/yandex_cloud + email, без дублей
|
||||
expect($rows)->toHaveCount(3); // строк по-прежнему 3, без дублей
|
||||
});
|
||||
|
||||
it('упавший провайдер не роняет джобу и сохраняет ошибку, остальные пишутся', function () {
|
||||
@@ -81,10 +70,9 @@ it('пишет строки живости: balance_amount NULL, цвет из
|
||||
app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100)));
|
||||
app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null)));
|
||||
app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600)));
|
||||
// email — денежный (переопределяем дефолтный стаб из beforeEach конкретной суммой).
|
||||
app()->instance(Yandex360BalanceProvider::class, fakeProvider('email', BalanceReading::ok('email', 777, 'RUB', null)));
|
||||
|
||||
RefreshExternalBalancesJob::useLivenessProbes([
|
||||
fakeProbe('email', LivenessReading::alive('email', 'SMTP отвечает')),
|
||||
fakeProbe('jivosite', LivenessReading::down('jivosite', 'HTTP 500')),
|
||||
fakeProbe('captcha', LivenessReading::unknown('captcha', 'выключена')),
|
||||
]);
|
||||
@@ -92,8 +80,9 @@ it('пишет строки живости: balance_amount NULL, цвет из
|
||||
(new RefreshExternalBalancesJob)->handle();
|
||||
|
||||
$rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get()->keyBy('service_key');
|
||||
expect($rows)->toHaveCount(6); // 4 деньги (вкл. email) + 2 живость
|
||||
expect((float) $rows['email']->balance_amount)->toBe(777.0);
|
||||
expect($rows)->toHaveCount(6); // 3 деньги + 3 живость
|
||||
expect($rows['email']->balance_amount)->toBeNull();
|
||||
expect($rows['email']->light)->toBe('green');
|
||||
expect((bool) $rows['email']->ok)->toBeTrue();
|
||||
expect($rows['jivosite']->light)->toBe('red');
|
||||
expect((bool) $rows['jivosite']->ok)->toBeTrue(); // ok=true: статус свежий и определённый (упал)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SalesUser;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* TDD: SalesAuthController — login / me / logout.
|
||||
*
|
||||
* Покрывает Task 0.5 + 0.6 портала продаж:
|
||||
* - POST /api/sales/auth/login → 200 (token + user) / 422 (неверный логин) / 403 (отключён)
|
||||
* - GET /api/sales/auth/me → 401 без токена / 200 с токеном
|
||||
* - POST /api/sales/auth/logout → 200; повторный GET /me → 401
|
||||
*
|
||||
* Маршруты идут через middleware admin-db (UseAdminConnection), который
|
||||
* переключает default-соединение на pgsql_admin (crm_admin_user). В тестах
|
||||
* SharesAdminPdo (глобально в Pest.php) связывает pgsql и pgsql_admin через
|
||||
* один PDO, чтобы данные, засеянные через pgsql, были видны запросу.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Tasks 0.5–0.6)
|
||||
*/
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Создаёт SalesUser с известным паролем и возвращает [user, plainPassword].
|
||||
*
|
||||
* @return array{0: SalesUser, 1: string}
|
||||
*/
|
||||
function makeSalesUserWithPassword(bool $active = true): array
|
||||
{
|
||||
$plain = 'SecretPass-'.uniqid();
|
||||
$user = SalesUser::create([
|
||||
'name' => 'Тест Менеджер '.uniqid(),
|
||||
'email' => 'salesauth-'.uniqid().'@test.local',
|
||||
'password' => Hash::make($plain),
|
||||
'role' => 'manager',
|
||||
'is_active' => $active,
|
||||
]);
|
||||
|
||||
return [$user, $plain];
|
||||
}
|
||||
|
||||
// ─── login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test('login: верные данные активного пользователя → 200, token и user.role', function () {
|
||||
[$user, $plain] = makeSalesUserWithPassword();
|
||||
|
||||
$response = $this->postJson('/api/sales/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => $plain,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('token'))->not->toBeNull()->not->toBe('');
|
||||
expect($response->json('user.role'))->toBe('manager');
|
||||
expect($response->json('user.email'))->toBe($user->email);
|
||||
});
|
||||
|
||||
test('login: неверный пароль → 422 с сообщением', function () {
|
||||
[$user] = makeSalesUserWithPassword();
|
||||
|
||||
$response = $this->postJson('/api/sales/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password-xyz',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->json('message'))->toBe('Неверный логин или пароль.');
|
||||
});
|
||||
|
||||
test('login: несуществующий email → 422 с сообщением', function () {
|
||||
$response = $this->postJson('/api/sales/auth/login', [
|
||||
'email' => 'nonexistent-'.uniqid().'@test.local',
|
||||
'password' => 'some-password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->json('message'))->toBe('Неверный логин или пароль.');
|
||||
});
|
||||
|
||||
test('login: неактивный пользователь с верным паролем → 403', function () {
|
||||
[$user, $plain] = makeSalesUserWithPassword(active: false);
|
||||
|
||||
$response = $this->postJson('/api/sales/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => $plain,
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
expect($response->json('message'))->toBe('Аккаунт отключён, обратитесь к начальнику.');
|
||||
});
|
||||
|
||||
// ─── me ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('me: запрос без токена → 401', function () {
|
||||
$this->getJson('/api/sales/auth/me')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('me: запрос с валидным Bearer-токеном → 200 с данными пользователя', function () {
|
||||
[$user, $plain] = makeSalesUserWithPassword();
|
||||
|
||||
// Получаем токен через логин.
|
||||
$loginResponse = $this->postJson('/api/sales/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => $plain,
|
||||
]);
|
||||
$loginResponse->assertOk();
|
||||
$token = $loginResponse->json('token');
|
||||
|
||||
// Запрашиваем /me с токеном.
|
||||
$meResponse = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/api/sales/auth/me');
|
||||
|
||||
$meResponse->assertOk();
|
||||
expect($meResponse->json('email'))->toBe($user->email);
|
||||
expect($meResponse->json('role'))->toBe('manager');
|
||||
});
|
||||
|
||||
// ─── logout ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('logout: выход инвалидирует токен, повторный /me → 401', function () {
|
||||
[$user, $plain] = makeSalesUserWithPassword();
|
||||
|
||||
// Создаём токен напрямую (без login endpoint) — стабильнее в тестах.
|
||||
// Проверка самого login-эндпоинта покрыта первым тестом выше.
|
||||
$plainToken = $user->createToken('sales')->plainTextToken;
|
||||
|
||||
// Выход через endpoint.
|
||||
$logoutResponse = $this->withHeader('Authorization', 'Bearer '.$plainToken)
|
||||
->postJson('/api/sales/auth/logout');
|
||||
$logoutResponse->assertOk();
|
||||
expect($logoutResponse->json('message'))->toBe('Вы вышли.');
|
||||
|
||||
// Сбрасываем кэш guard-инстансов в AuthManager (общий singleton в контейнере).
|
||||
// Без этого guard('sales') возвращает пользователя, кэшированного из предыдущего
|
||||
// logout-запроса, даже если токен уже удалён из БД.
|
||||
Auth::forgetGuards();
|
||||
|
||||
// Повторный /me с тем же токеном должен вернуть 401.
|
||||
$this->withHeader('Authorization', 'Bearer '.$plainToken)
|
||||
->getJson('/api/sales/auth/me')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SalesUser;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* TDD: guard «sales» (Sanctum driver, provider sales_users).
|
||||
*
|
||||
* Проверяет, что:
|
||||
* 1. Bearer-токен sales_user открывает доступ к маршруту auth:sales.
|
||||
* 2. Запрос без токена получает 401.
|
||||
*
|
||||
* Примечание по кросс-модельной проверке:
|
||||
* Стандартный tenant User (App\Models\User) в этом проекте НЕ использует
|
||||
* HasApiTokens (SPA cookie-auth), поэтому createToken() на User недоступен.
|
||||
* Кросс-модельная изоляция guard'а sales гарантируется архитектурно:
|
||||
* Sanctum привязывает tokenable_type к модели в personal_access_tokens;
|
||||
* guard с provider sales_users резолвит только записи, где tokenable_type =
|
||||
* App\Models\SalesUser. Тест на «чужой токен» возможен между двумя
|
||||
* разными SalesUser — разные пользователи корректно разрешаются (auth OK
|
||||
* для обоих), но привязка к конкретному пользователю верна.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.3)
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Стаб-маршрут, защищённый guard'ом sales.
|
||||
Route::middleware('auth:sales')->get('/__sales_ping', fn () => response()->json(['ok' => true]));
|
||||
});
|
||||
|
||||
test('sales user с valid Bearer-токеном получает 200 на auth:sales маршруте', function () {
|
||||
$salesUser = SalesUser::create([
|
||||
'name' => 'Тест Менеджер',
|
||||
'email' => 'sales-guard-test-'.uniqid().'@test.local',
|
||||
'password' => Hash::make('test-secret-123'),
|
||||
'role' => 'manager',
|
||||
]);
|
||||
|
||||
$token = $salesUser->createToken('test')->plainTextToken;
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson('/__sales_ping')
|
||||
->assertOk()
|
||||
->assertJson(['ok' => true]);
|
||||
});
|
||||
|
||||
test('запрос без токена на auth:sales маршрут получает 401', function () {
|
||||
$this->getJson('/__sales_ping')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('невалидный Bearer-токен на auth:sales маршруте получает 401', function () {
|
||||
// Произвольная строка, которой нет в personal_access_tokens.
|
||||
$this->withHeader('Authorization', 'Bearer '.str_repeat('x', 64))
|
||||
->getJson('/__sales_ping')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use App\Services\Sales\SalesMetricsService;
|
||||
use App\Services\Sales\SalesPeriodRange;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* TDD: SalesMetricsService — метрики периода для портала продаж.
|
||||
*
|
||||
* Покрывает Task 1.2: leadsDelivered / oborotRub / topupsRub /
|
||||
* cumulativeTopupsRub / runwayDays.
|
||||
*
|
||||
* Изоляция: DatabaseTransactions — откат в конце каждого теста.
|
||||
* Использует DEFAULT connection (pgsql → liderra_testing).
|
||||
*
|
||||
* CRITICAL: деньги — INTEGER kopecks → SUM → / 100. Float-суммирование запрещено.
|
||||
* Интервал: half-open [range.start, range.end.startOfDay().addDay()).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Строим SalesPeriodRange по строкам дат (МСК).
|
||||
*/
|
||||
function makePeriodRange(string $startDate, string $endDate): SalesPeriodRange
|
||||
{
|
||||
$tz = 'Europe/Moscow';
|
||||
|
||||
return new SalesPeriodRange(
|
||||
CarbonImmutable::parse($startDate.' 00:00:00', $tz),
|
||||
CarbonImmutable::parse($endDate.' 23:59:59', $tz),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт тенанта с балансом и активным проектом.
|
||||
*
|
||||
* @return array{tenant: Tenant, project: Project}
|
||||
*/
|
||||
function makeTenantWithProject(float $balanceRub = 10000.0, int $dailyLimit = 10): array
|
||||
{
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => $balanceRub,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => $dailyLimit,
|
||||
]);
|
||||
|
||||
return ['tenant' => $tenant, 'project' => $project];
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет deal напрямую через DB::table (обходим RLS — в тестах superuser).
|
||||
*/
|
||||
function insertDeal(int $tenantId, int $projectId, string $receivedAt, bool $isTest = false, ?int $duplicateOfId = null, bool $softDeleted = false): void
|
||||
{
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'project_id' => $projectId,
|
||||
'source_crm_id' => fake()->unique()->numberBetween(100_000_000, 999_999_999),
|
||||
'phone' => '7'.fake()->numerify('##########'),
|
||||
'status' => 'new',
|
||||
'contact_name' => 'Test Lead',
|
||||
'escalated_count' => 0,
|
||||
'is_test' => $isTest,
|
||||
'duplicate_of_id' => $duplicateOfId,
|
||||
'received_at' => $receivedAt,
|
||||
'created_at' => $receivedAt,
|
||||
'updated_at' => $receivedAt,
|
||||
'deleted_at' => $softDeleted ? $receivedAt : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет lead_charge напрямую.
|
||||
*/
|
||||
function insertLeadCharge(int $tenantId, int $kopecks, string $chargedAt): void
|
||||
{
|
||||
DB::table('lead_charges')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'deal_id' => fake()->numberBetween(1, 99999),
|
||||
'deal_received_at' => $chargedAt,
|
||||
'tier_no' => 1,
|
||||
'price_per_lead_kopecks' => $kopecks,
|
||||
'charge_source' => 'rub',
|
||||
'charged_at' => $chargedAt,
|
||||
'created_at' => $chargedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет balance_transaction напрямую.
|
||||
*/
|
||||
function insertBalanceTx(int $tenantId, string $type, string $amountRub, string $createdAt): void
|
||||
{
|
||||
DB::table('balance_transactions')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'type' => $type,
|
||||
'amount_rub' => $amountRub,
|
||||
'amount_leads' => 0,
|
||||
'balance_rub_after' => $amountRub,
|
||||
'description' => 'test',
|
||||
'created_at' => $createdAt,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 1. leadsDelivered ──────────────────────────────────────────────────────
|
||||
|
||||
test('leadsDelivered: считает только not-deleted, not-test сделки в диапазоне', function () {
|
||||
['tenant' => $tenant, 'project' => $project] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
// 3 нормальных в диапазоне
|
||||
insertDeal($tenant->id, $project->id, '2026-06-15 10:00:00');
|
||||
insertDeal($tenant->id, $project->id, '2026-06-20 10:00:00');
|
||||
insertDeal($tenant->id, $project->id, '2026-06-01 00:00:00'); // ровно старт
|
||||
|
||||
// soft-deleted — НЕ считается
|
||||
insertDeal($tenant->id, $project->id, '2026-06-10 12:00:00', softDeleted: true);
|
||||
|
||||
// is_test — НЕ считается
|
||||
insertDeal($tenant->id, $project->id, '2026-06-12 09:00:00', isTest: true);
|
||||
|
||||
// вне диапазона (следующий день после endDate)
|
||||
insertDeal($tenant->id, $project->id, '2026-07-01 00:00:00');
|
||||
|
||||
// вне диапазона (до startDate)
|
||||
insertDeal($tenant->id, $project->id, '2026-05-31 23:59:59');
|
||||
|
||||
expect($service->leadsDelivered($tenant->id, $range))->toBe(3);
|
||||
});
|
||||
|
||||
test('leadsDelivered: дубли (duplicate_of_id != null) ВКЛЮЧАЮТСЯ — совпадает с DashboardController', function () {
|
||||
['tenant' => $tenant, 'project' => $project] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
// Дубль — duplicate_of_id заполнен, но сделка не удалена → СЧИТАЕТСЯ
|
||||
insertDeal($tenant->id, $project->id, '2026-06-15 10:00:00', duplicateOfId: 12345);
|
||||
insertDeal($tenant->id, $project->id, '2026-06-16 10:00:00');
|
||||
|
||||
expect($service->leadsDelivered($tenant->id, $range))->toBe(2);
|
||||
});
|
||||
|
||||
test('leadsDelivered: boundary — сделка ровно в 23:59:59 последнего дня включается', function () {
|
||||
['tenant' => $tenant, 'project' => $project] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
// 23:59:59 последнего дня — ВКЛЮЧАЕТСЯ
|
||||
insertDeal($tenant->id, $project->id, '2026-06-30 23:59:59');
|
||||
// следующая полночь — НЕ включается
|
||||
insertDeal($tenant->id, $project->id, '2026-07-01 00:00:00');
|
||||
|
||||
expect($service->leadsDelivered($tenant->id, $range))->toBe(1);
|
||||
});
|
||||
|
||||
test('leadsDelivered: другой тенант не считается', function () {
|
||||
['tenant' => $tenant, 'project' => $project] = makeTenantWithProject();
|
||||
['tenant' => $other, 'project' => $otherProject] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
insertDeal($other->id, $otherProject->id, '2026-06-15 10:00:00');
|
||||
|
||||
expect($service->leadsDelivered($tenant->id, $range))->toBe(0);
|
||||
});
|
||||
|
||||
// ── 2. oborotRub ──────────────────────────────────────────────────────────
|
||||
|
||||
test('oborotRub: SUM(price_per_lead_kopecks) / 100 только за период', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
// 3 × 15000 kopecks в диапазоне = 450 руб.
|
||||
insertLeadCharge($tenant->id, 15000, '2026-06-10 10:00:00');
|
||||
insertLeadCharge($tenant->id, 15000, '2026-06-15 10:00:00');
|
||||
insertLeadCharge($tenant->id, 15000, '2026-06-29 10:00:00');
|
||||
|
||||
// 1 вне диапазона
|
||||
insertLeadCharge($tenant->id, 15000, '2026-07-01 00:00:00');
|
||||
|
||||
expect($service->oborotRub($tenant->id, $range))->toBe(450.0);
|
||||
});
|
||||
|
||||
test('oborotRub: пустой период → 0.0', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
expect($service->oborotRub($tenant->id, $range))->toBe(0.0);
|
||||
});
|
||||
|
||||
test('oborotRub: boundary — заряд в 23:59:59 последнего дня включается, в следующую полночь — нет', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
insertLeadCharge($tenant->id, 20000, '2026-06-30 23:59:59'); // 200 руб — включается
|
||||
insertLeadCharge($tenant->id, 50000, '2026-07-01 00:00:00'); // 500 руб — НЕ включается
|
||||
|
||||
expect($service->oborotRub($tenant->id, $range))->toBe(200.0);
|
||||
});
|
||||
|
||||
test('oborotRub: не суммирует float-ошибки — целочисленный kopeck SUM', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
// 7 × 15700 kopecks = 109900 kopecks = 1099.00 руб. (без float-погрешности)
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
insertLeadCharge($tenant->id, 15700, '2026-06-15 10:00:00');
|
||||
}
|
||||
|
||||
// 7 * 15700 = 109900 kopecks = 1099.00 rub
|
||||
expect($service->oborotRub($tenant->id, $range))->toBe(1099.0);
|
||||
});
|
||||
|
||||
// ── 3. topupsRub ──────────────────────────────────────────────────────────
|
||||
|
||||
test('topupsRub: суммирует только type=topup за период', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-07-01', '2026-07-31');
|
||||
|
||||
// 3 topup в диапазоне
|
||||
insertBalanceTx($tenant->id, 'topup', '1000.00', '2026-07-05 10:00:00');
|
||||
insertBalanceTx($tenant->id, 'topup', '2000.00', '2026-07-20 10:00:00');
|
||||
insertBalanceTx($tenant->id, 'topup', '500.00', '2026-07-30 10:00:00');
|
||||
|
||||
// topup вне диапазона (в следующем месяце — попадает в другую партицию)
|
||||
insertBalanceTx($tenant->id, 'topup', '9999.00', '2026-08-01 00:00:00');
|
||||
// topup до начала диапазона — в предыдущем месяце (в существующей партиции 2026-06)
|
||||
insertBalanceTx($tenant->id, 'topup', '8888.00', '2026-06-30 10:00:00');
|
||||
|
||||
// другой тип в диапазоне — НЕ считается
|
||||
insertBalanceTx($tenant->id, 'lead_charge', '100.00', '2026-07-15 10:00:00');
|
||||
insertBalanceTx($tenant->id, 'manual_adjustment', '50.00', '2026-07-16 10:00:00');
|
||||
|
||||
expect($service->topupsRub($tenant->id, $range))->toBe(3500.0);
|
||||
});
|
||||
|
||||
test('topupsRub: boundary — topup в 23:59:59 последнего дня включается, в следующую полночь — нет', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
$range = makePeriodRange('2026-06-01', '2026-06-30');
|
||||
|
||||
insertBalanceTx($tenant->id, 'topup', '100.00', '2026-06-30 23:59:59'); // включается
|
||||
insertBalanceTx($tenant->id, 'topup', '999.00', '2026-07-01 00:00:00'); // НЕ включается
|
||||
|
||||
expect($service->topupsRub($tenant->id, $range))->toBe(100.0);
|
||||
});
|
||||
|
||||
// ── 4. cumulativeTopupsRub ────────────────────────────────────────────────
|
||||
|
||||
test('cumulativeTopupsRub: суммирует ВСЕ topup этого тенанта за всё время', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
|
||||
// Три транзакции в разные партиции (все существуют в liderra_testing: 2026-06, 2026-07, 2026-08)
|
||||
insertBalanceTx($tenant->id, 'topup', '1000.00', '2026-06-10 10:00:00');
|
||||
insertBalanceTx($tenant->id, 'topup', '2000.00', '2026-07-15 10:00:00');
|
||||
insertBalanceTx($tenant->id, 'topup', '500.00', '2026-08-01 09:00:00');
|
||||
|
||||
// Другой тип — НЕ считается
|
||||
insertBalanceTx($tenant->id, 'lead_charge', '9999.00', '2026-06-20 10:00:00');
|
||||
|
||||
expect($service->cumulativeTopupsRub($tenant->id))->toBe(3500.0);
|
||||
});
|
||||
|
||||
test('cumulativeTopupsRub: другой тенант не считается', function () {
|
||||
['tenant' => $tenant] = makeTenantWithProject();
|
||||
['tenant' => $other] = makeTenantWithProject();
|
||||
$service = new SalesMetricsService;
|
||||
|
||||
insertBalanceTx($other->id, 'topup', '5000.00', '2026-06-01 10:00:00');
|
||||
|
||||
expect($service->cumulativeTopupsRub($tenant->id))->toBe(0.0);
|
||||
});
|
||||
|
||||
// ── 5. runwayDays ─────────────────────────────────────────────────────────
|
||||
|
||||
test('runwayDays: возвращает то же что RunwayCalculator для одинаковых входных данных', function () {
|
||||
// Создаём PricingTier чтобы BalanceToLeadsConverter имел данные.
|
||||
PricingTier::factory()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null, // безлимитный
|
||||
'price_per_lead_kopecks' => 2000, // 20 руб/лид
|
||||
'is_active' => true,
|
||||
'effective_from' => '2020-01-01',
|
||||
]);
|
||||
|
||||
// Тенант с балансом 10000 руб., 0 delivered_in_month, проект с лимитом 10
|
||||
['tenant' => $tenant, 'project' => $project] = makeTenantWithProject(balanceRub: 10000.0, dailyLimit: 10);
|
||||
|
||||
$service = new SalesMetricsService;
|
||||
$result = $service->runwayDays($tenant->id);
|
||||
|
||||
// Вычисляем ожидаемое: 10000 руб → сколько лидов по 20 руб = 500 лидов
|
||||
// daily_limit = 10 → runway = 500 / 10 = 50 дней
|
||||
$activeTiers = app(PricingTierRepository::class)
|
||||
->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$conversion = app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
(int) ($tenant->delivered_in_month ?? 0),
|
||||
$activeTiers,
|
||||
);
|
||||
$affordableLeads = (int) $conversion['leads'];
|
||||
$expected = app(RunwayCalculator::class)->daysLeft($tenant->id, $affordableLeads);
|
||||
|
||||
expect($result)->toBe($expected)->toBe(50);
|
||||
});
|
||||
|
||||
test('runwayDays: нет активных проектов → null (нечего считать)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => 5000.0,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
// Проект есть но не активен
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => false,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
|
||||
PricingTier::factory()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 2000,
|
||||
'is_active' => true,
|
||||
'effective_from' => '2020-01-01',
|
||||
]);
|
||||
|
||||
$service = new SalesMetricsService;
|
||||
expect($service->runwayDays($tenant->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('runwayDays: нулевой баланс → 0 дней', function () {
|
||||
PricingTier::factory()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 2000,
|
||||
'is_active' => true,
|
||||
'effective_from' => '2020-01-01',
|
||||
]);
|
||||
|
||||
['tenant' => $tenant] = makeTenantWithProject(balanceRub: 0.0, dailyLimit: 10);
|
||||
$service = new SalesMetricsService;
|
||||
expect($service->runwayDays($tenant->id))->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SalesAttachmentRequest;
|
||||
use App\Models\SalesClientAssignment;
|
||||
use App\Models\SalesPayout;
|
||||
use App\Models\SalesTariff;
|
||||
use App\Models\SalesUser;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Smoke-тесты Eloquent-моделей портала продаж.
|
||||
* Проверяют: создание записей, каsты, связи, вспомогательные методы.
|
||||
*
|
||||
* Изоляция: DatabaseTransactions — каждый тест оборачивается в транзакцию
|
||||
* и откатывается, не оставляя строк в liderra_testing.
|
||||
*
|
||||
* Используем DEFAULT connection (pgsql → liderra_testing в тестах).
|
||||
* sales_* таблицы созданы Task 0.1 (migration 2026_07_01_100000_create_sales_portal_tables.php).
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSalesTariff(array $attrs = []): SalesTariff
|
||||
{
|
||||
return SalesTariff::create(array_merge([
|
||||
'name' => 'Test tariff '.uniqid(),
|
||||
'kind' => 'topup_step',
|
||||
'params' => ['step' => 1000, 'bonus' => 100],
|
||||
'is_active' => true,
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
function makeSalesUser(array $attrs = []): SalesUser
|
||||
{
|
||||
return SalesUser::create(array_merge([
|
||||
'name' => 'Manager '.uniqid(),
|
||||
'email' => 'mgr'.uniqid().'@test.local',
|
||||
'password' => bcrypt('secret'),
|
||||
'role' => 'manager',
|
||||
], $attrs));
|
||||
}
|
||||
|
||||
// ── 1. SalesTariff ─────────────────────────────────────────────────────────
|
||||
|
||||
test('SalesTariff можно создать и params отдаётся массивом', function () {
|
||||
$tariff = makeSalesTariff([
|
||||
'params' => ['step' => 5000, 'bonus' => 500],
|
||||
]);
|
||||
|
||||
expect($tariff->id)->toBeInt()
|
||||
->and($tariff->name)->toStartWith('Test tariff')
|
||||
->and($tariff->kind)->toBe('topup_step')
|
||||
->and($tariff->params)->toBeArray()
|
||||
->and($tariff->params['step'])->toBe(5000)
|
||||
->and($tariff->is_active)->toBeTrue();
|
||||
});
|
||||
|
||||
// ── 2. SalesUser ───────────────────────────────────────────────────────────
|
||||
|
||||
test('SalesUser создаётся с role=manager и current_tariff_id', function () {
|
||||
$tariff = makeSalesTariff();
|
||||
$user = makeSalesUser([
|
||||
'role' => 'manager',
|
||||
'current_tariff_id' => $tariff->id,
|
||||
'base_salary_rub' => '50000.00',
|
||||
]);
|
||||
|
||||
expect($user->id)->toBeInt()
|
||||
->and($user->role)->toBe('manager')
|
||||
->and($user->current_tariff_id)->toBe($tariff->id)
|
||||
->and($user->fresh()->is_active)->toBeTrue(); // DB default=TRUE, fresh() синхронизирует
|
||||
});
|
||||
|
||||
test('SalesUser::isHead() верен для role=head и false для role=manager', function () {
|
||||
$head = makeSalesUser(['role' => 'head']);
|
||||
$manager = makeSalesUser(['role' => 'manager']);
|
||||
|
||||
expect($head->isHead())->toBeTrue()
|
||||
->and($manager->isHead())->toBeFalse();
|
||||
});
|
||||
|
||||
test('SalesUser is_active приходит булевым', function () {
|
||||
$user = makeSalesUser(['is_active' => true]);
|
||||
|
||||
expect($user->is_active)->toBeBool()->toBeTrue();
|
||||
});
|
||||
|
||||
// ── 3. SalesClientAssignment ───────────────────────────────────────────────
|
||||
|
||||
test('SalesClientAssignment связывает пользователя с тенантом и tariff_params — массив', function () {
|
||||
$tariff = makeSalesTariff(['kind' => 'percent_oborot', 'params' => ['percent' => 5]]);
|
||||
$user = makeSalesUser(['current_tariff_id' => $tariff->id]);
|
||||
$tenant = Tenant::factory()->create(); // реальный тенант (FK tenants.id)
|
||||
|
||||
$assignment = SalesClientAssignment::create([
|
||||
'sales_user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'tariff_id' => $tariff->id,
|
||||
'tariff_kind' => $tariff->kind,
|
||||
'tariff_params' => ['percent' => 5],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
expect($assignment->id)->toBeInt()
|
||||
->and($assignment->tariff_params)->toBeArray()
|
||||
->and($assignment->tariff_params['percent'])->toBe(5)
|
||||
->and($assignment->assigned_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
|
||||
test('SalesUser->assignments() возвращает HasMany-коллекцию', function () {
|
||||
$user = makeSalesUser();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $user->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
$relation = $user->assignments();
|
||||
expect($relation)->toBeInstanceOf(HasMany::class);
|
||||
|
||||
$collection = $user->assignments;
|
||||
expect($collection)->toHaveCount(1)
|
||||
->and($collection->first())->toBeInstanceOf(SalesClientAssignment::class);
|
||||
});
|
||||
|
||||
// ── 4. SalesAttachmentRequest ──────────────────────────────────────────────
|
||||
|
||||
test('SalesAttachmentRequest создаётся со статусом pending', function () {
|
||||
$user = makeSalesUser();
|
||||
|
||||
$req = SalesAttachmentRequest::create([
|
||||
'sales_user_id' => $user->id,
|
||||
'login_input' => 'client@example.com',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
expect($req->id)->toBeInt()
|
||||
->and($req->status)->toBe('pending')
|
||||
->and($req->decided_at)->toBeNull();
|
||||
});
|
||||
|
||||
// ── 5. SalesPayout ─────────────────────────────────────────────────────────
|
||||
|
||||
test('SalesPayout создаётся, amount_rub — decimal, paid_on — date', function () {
|
||||
$user = makeSalesUser();
|
||||
|
||||
$payout = SalesPayout::create([
|
||||
'sales_user_id' => $user->id,
|
||||
'amount_rub' => '12500.50',
|
||||
'paid_on' => today(),
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
expect($payout->id)->toBeInt()
|
||||
->and((float) $payout->amount_rub)->toBe(12500.50)
|
||||
->and($payout->paid_on)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
|
||||
test('SalesPayout append-only: UPDATE бросает исключение', function () {
|
||||
$user = makeSalesUser();
|
||||
|
||||
$payout = SalesPayout::create([
|
||||
'sales_user_id' => $user->id,
|
||||
'amount_rub' => '100.00',
|
||||
'paid_on' => today(),
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
expect(fn () => $payout->update(['amount_rub' => '999.00']))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Concerns\ScopesSalesOwnership;
|
||||
use App\Models\SalesClientAssignment;
|
||||
use App\Models\SalesUser;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* TDD: трейт ScopesSalesOwnership.
|
||||
*
|
||||
* Проверяет логику ограничения ownership:
|
||||
* - Менеджер видит только своих клиентов (tenant_ids из sales_client_assignments).
|
||||
* - Начальник (role=head) видит всех — ограничение не применяется (null).
|
||||
*
|
||||
* Изоляция: DatabaseTransactions — каждый тест откатывается.
|
||||
* Используем DEFAULT connection (pgsql → liderra_testing).
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-06-30-sales-portal.md (Task 0.4)
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Зонд для тестирования трейта без реального контроллера.
|
||||
*/
|
||||
class OwnershipProbe
|
||||
{
|
||||
use ScopesSalesOwnership;
|
||||
|
||||
public function __construct(private SalesUser $u) {}
|
||||
|
||||
/** @return list<int>|null */
|
||||
public function ids(): ?array
|
||||
{
|
||||
return $this->ownedTenantIds($this->u);
|
||||
}
|
||||
|
||||
public function scoped(Builder $query): Builder
|
||||
{
|
||||
return $this->scopeByOwnership($query, $this->u);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSalesUserOwn(string $role = 'manager'): SalesUser
|
||||
{
|
||||
return SalesUser::create([
|
||||
'name' => ucfirst($role).' '.uniqid(),
|
||||
'email' => $role.uniqid().'@scope.local',
|
||||
'password' => Hash::make('secret'),
|
||||
'role' => $role,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── 1. ownedTenantIds ──────────────────────────────────────────────────────
|
||||
|
||||
test('ownedTenantIds для менеджера возвращает список его tenant_id', function () {
|
||||
$manager = makeSalesUserOwn('manager');
|
||||
|
||||
$t1 = Tenant::factory()->create();
|
||||
$t2 = Tenant::factory()->create();
|
||||
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $t1->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $t2->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
$probe = new OwnershipProbe($manager);
|
||||
$ids = $probe->ids();
|
||||
|
||||
expect($ids)->toBeArray()
|
||||
->toEqualCanonicalizing([$t1->id, $t2->id]);
|
||||
});
|
||||
|
||||
test('ownedTenantIds для head возвращает null (нет ограничения)', function () {
|
||||
$head = makeSalesUserOwn('head');
|
||||
|
||||
$probe = new OwnershipProbe($head);
|
||||
|
||||
expect($probe->ids())->toBeNull();
|
||||
});
|
||||
|
||||
test('ownedTenantIds для менеджера без клиентов возвращает пустой массив', function () {
|
||||
$manager = makeSalesUserOwn('manager');
|
||||
|
||||
$probe = new OwnershipProbe($manager);
|
||||
$ids = $probe->ids();
|
||||
|
||||
expect($ids)->toBeArray()->toBeEmpty();
|
||||
});
|
||||
|
||||
// ── 2. scopeByOwnership ────────────────────────────────────────────────────
|
||||
|
||||
test('scopeByOwnership для менеджера ограничивает запрос его tenant_id', function () {
|
||||
$manager = makeSalesUserOwn('manager');
|
||||
$other = makeSalesUserOwn('manager');
|
||||
|
||||
$t1 = Tenant::factory()->create();
|
||||
$t2 = Tenant::factory()->create();
|
||||
$t3 = Tenant::factory()->create();
|
||||
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $t1->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $t2->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
// t3 принадлежит другому менеджеру
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $other->id,
|
||||
'tenant_id' => $t3->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
$probe = new OwnershipProbe($manager);
|
||||
$results = $probe->scoped(SalesClientAssignment::query())->get();
|
||||
|
||||
$tenantIds = $results->pluck('tenant_id')->sort()->values()->all();
|
||||
|
||||
expect($tenantIds)->toEqualCanonicalizing([$t1->id, $t2->id]);
|
||||
});
|
||||
|
||||
test('scopeByOwnership для head не ограничивает запрос (видит всех)', function () {
|
||||
$head = makeSalesUserOwn('head');
|
||||
$manager = makeSalesUserOwn('manager');
|
||||
|
||||
$t1 = Tenant::factory()->create();
|
||||
$t2 = Tenant::factory()->create();
|
||||
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $t1->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $manager->id,
|
||||
'tenant_id' => $t2->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
$probe = new OwnershipProbe($head);
|
||||
$results = $probe->scoped(SalesClientAssignment::query())->get();
|
||||
|
||||
// Head видит все записи (в контексте транзакции — минимум 2 наши)
|
||||
expect($results->count())->toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('scopeByOwnership для менеджера без клиентов возвращает пустую коллекцию', function () {
|
||||
$manager = makeSalesUserOwn('manager');
|
||||
|
||||
// Создаём другого менеджера с клиентом, чтобы таблица не была пустой
|
||||
$other = makeSalesUserOwn('manager');
|
||||
$t1 = Tenant::factory()->create();
|
||||
SalesClientAssignment::create([
|
||||
'sales_user_id' => $other->id,
|
||||
'tenant_id' => $t1->id,
|
||||
'tariff_params' => [],
|
||||
'assigned_at' => now(),
|
||||
]);
|
||||
|
||||
$probe = new OwnershipProbe($manager);
|
||||
$results = $probe->scoped(SalesClientAssignment::query())->get();
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
@@ -105,6 +105,61 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl
|
||||
expect($pivotCount)->toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zero-share platforms are skipped (new cabinet rejects limit=0 — "Введите limit!")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* daily_limit_target=1 site → order=1 → distributeForPlatform = ['B1'=>1] only.
|
||||
* The job must send exactly ONE rt-project-save (B1), NOT three: B2/B3 have a 0 share
|
||||
* and the crm.lead.store cabinet rejects limit=0 ("Введите limit!", verified live 2026-07-01).
|
||||
*/
|
||||
test('limit-1 site project → only B1 saved, B2/B3 (0-share) skipped', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var Project $project */
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'limit1.example.com',
|
||||
'daily_limit_target' => 1,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, regions: '{}');
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '2001', 'src' => 'rt', 'name' => 'limit1.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'limit1.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// Only B1 supplier_project created — B2/B3 (0 share) never saved.
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'limit1.example.com')
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
expect($sps)->toHaveCount(1);
|
||||
expect($sps->first()->platform)->toBe('B1');
|
||||
expect((int) $sps->first()->current_limit)->toBe(1);
|
||||
|
||||
// Discriminating assertion: exactly ONE rt-project-save was sent (not three).
|
||||
$saveCalls = collect(Http::recorded())
|
||||
->filter(fn ($pair) => str_contains($pair[0]->url(), '/admin/visit/rt-project-save'))
|
||||
->count();
|
||||
expect($saveCalls)->toBe(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All-RF pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import SalesClientsView from '../../resources/js/views/sales/SalesClientsView.vue';
|
||||
|
||||
// ─── mock listSalesClients ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../resources/js/api/sales', () => ({
|
||||
listSalesClients: vi.fn().mockResolvedValue([
|
||||
{
|
||||
tenant_id: 101,
|
||||
organization_name: 'Окна Москва ООО',
|
||||
inn: '7724444444',
|
||||
subject_type: 'legal_entity',
|
||||
last_activity_at: null,
|
||||
balance_rub: '14250',
|
||||
status: 'active',
|
||||
tariff_name: 'Пополнения·Станд.',
|
||||
projects_count: 3,
|
||||
runway_days: 9,
|
||||
leads_delivered: 331,
|
||||
oborot_rub: 49650,
|
||||
earned_rub: null,
|
||||
},
|
||||
{
|
||||
tenant_id: 202,
|
||||
organization_name: 'Натяжные потолки СПб',
|
||||
inn: '7805123456',
|
||||
subject_type: 'sole_proprietor',
|
||||
last_activity_at: null,
|
||||
balance_rub: '38100',
|
||||
status: 'overdue',
|
||||
tariff_name: 'Пополнения·Агро',
|
||||
projects_count: 2,
|
||||
runway_days: null,
|
||||
leads_delivered: 228,
|
||||
oborot_rub: 34200,
|
||||
earned_rub: null,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeRouter() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/sales/clients', name: 'sales-clients', component: SalesClientsView },
|
||||
{ path: '/sales/clients/:id', name: 'sales-client-detail', component: { template: '<div/>' } },
|
||||
{ path: '/sales', name: 'sales-overview', component: { template: '<div/>' } },
|
||||
],
|
||||
});
|
||||
return router.push('/sales/clients').then(() => router);
|
||||
}
|
||||
|
||||
describe('SalesClientsView', () => {
|
||||
it('отрисовывает заголовки колонок', async () => {
|
||||
const router = await makeRouter();
|
||||
const wrapper = mount(SalesClientsView, {
|
||||
global: { plugins: [createVuetify(), createPinia(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Клиент');
|
||||
expect(text).toContain('Тип');
|
||||
expect(text).toContain('Активность');
|
||||
expect(text).toContain('Баланс');
|
||||
expect(text).toContain('Запас');
|
||||
expect(text).toContain('Проектов');
|
||||
expect(text).toContain('Пришло');
|
||||
expect(text).toContain('Оборот');
|
||||
expect(text).toContain('Тариф');
|
||||
expect(text).toContain('Заработал');
|
||||
expect(text).toContain('Статус');
|
||||
});
|
||||
|
||||
it('отрисовывает обе строки клиентов', async () => {
|
||||
const router = await makeRouter();
|
||||
const wrapper = mount(SalesClientsView, {
|
||||
global: { plugins: [createVuetify(), createPinia(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Окна Москва ООО');
|
||||
expect(text).toContain('7724444444');
|
||||
expect(text).toContain('Натяжные потолки СПб');
|
||||
expect(text).toContain('7805123456');
|
||||
});
|
||||
|
||||
it('клик по строке вызывает router.push с /sales/clients/<tenant_id>', async () => {
|
||||
const router = await makeRouter();
|
||||
const pushSpy = vi.spyOn(router, 'push');
|
||||
const wrapper = mount(SalesClientsView, {
|
||||
global: { plugins: [createVuetify(), createPinia(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const rows = wrapper.findAll('[data-testid="client-row"]');
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
await rows[0].trigger('click');
|
||||
expect(pushSpy).toHaveBeenCalledWith('/sales/clients/101');
|
||||
|
||||
await rows[1].trigger('click');
|
||||
expect(pushSpy).toHaveBeenCalledWith('/sales/clients/202');
|
||||
});
|
||||
|
||||
it('колонка «Заработал» всегда показывает «—»', async () => {
|
||||
const router = await makeRouter();
|
||||
const wrapper = mount(SalesClientsView, {
|
||||
global: { plugins: [createVuetify(), createPinia(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const earnedCells = wrapper.findAll('[data-testid="earned-cell"]');
|
||||
expect(earnedCells.length).toBe(2);
|
||||
for (const cell of earnedCells) {
|
||||
expect(cell.text().trim()).toBe('—');
|
||||
}
|
||||
});
|
||||
|
||||
it('вызывает listSalesClients при монтировании', async () => {
|
||||
const router = await makeRouter();
|
||||
mount(SalesClientsView, {
|
||||
global: { plugins: [createVuetify(), createPinia(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const api = await import('../../resources/js/api/sales');
|
||||
expect(api.listSalesClients).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('статус «active» → чип «Активен», «overdue» → чип «Просрочка»', async () => {
|
||||
const router = await makeRouter();
|
||||
const wrapper = mount(SalesClientsView, {
|
||||
global: { plugins: [createVuetify(), createPinia(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Активен');
|
||||
expect(text).toContain('Просрочка');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
|
||||
// Мокаем api/sales до import'а SalesLoginView.
|
||||
// Используем importOriginal чтобы extractSalesErrorMessage работал из реального модуля.
|
||||
vi.mock('../../resources/js/api/sales', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../resources/js/api/sales')>();
|
||||
return {
|
||||
...actual,
|
||||
salesLogin: vi.fn(),
|
||||
salesMe: vi.fn(),
|
||||
salesLogout: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Мокаем localStorage (нужен для salesAuth store).
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, val: string) => {
|
||||
store[key] = val;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
import * as salesApi from '../../resources/js/api/sales';
|
||||
import { useSalesAuthStore } from '../../resources/js/stores/salesAuth';
|
||||
import SalesLoginView from '../../resources/js/views/sales/SalesLoginView.vue';
|
||||
|
||||
const mountLogin = async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/sales/login', component: SalesLoginView },
|
||||
{ path: '/sales', name: 'sales-overview', component: { template: '<div>overview</div>' } },
|
||||
],
|
||||
});
|
||||
await router.push('/sales/login');
|
||||
await router.isReady();
|
||||
return { wrapper: mount(SalesLoginView, { global: { plugins: [pinia, createVuetify(), router] } }), router };
|
||||
};
|
||||
|
||||
describe('SalesLoginView.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
it('монтируется и содержит заголовок «Вход»', async () => {
|
||||
const { wrapper } = await mountLogin();
|
||||
expect(wrapper.text()).toContain('Вход');
|
||||
});
|
||||
|
||||
it('содержит поле email, поле пароля и кнопку «Войти»', async () => {
|
||||
const { wrapper } = await mountLogin();
|
||||
// Должны быть email и password поля, но НЕ кнопки демо-ролей
|
||||
expect(
|
||||
wrapper.find('input[type="email"]').exists() ||
|
||||
wrapper.find('[data-testid="email-field"]').exists() ||
|
||||
wrapper.find('input').exists(),
|
||||
).toBe(true);
|
||||
expect(wrapper.text()).toContain('Войти');
|
||||
// Убеждаемся, что кнопок «Войти как менеджер / начальник» нет
|
||||
expect(wrapper.text()).not.toContain('Войти как менеджер');
|
||||
expect(wrapper.text()).not.toContain('Войти как начальник');
|
||||
});
|
||||
|
||||
it('после успешного submit вызывает salesAuth.login и навигирует на /sales', async () => {
|
||||
vi.mocked(salesApi.salesLogin).mockResolvedValue({
|
||||
token: 'tok-abc',
|
||||
user: { id: 1, name: 'Иван', email: 'ivan@example.ru', role: 'manager' as const },
|
||||
});
|
||||
|
||||
const { wrapper, router } = await mountLogin();
|
||||
const pushSpy = vi.spyOn(router, 'push');
|
||||
|
||||
// Заполняем email
|
||||
const inputs = wrapper.findAll('input');
|
||||
const emailInput =
|
||||
inputs.find((i) => i.attributes('type') === 'email' || i.attributes('autocomplete') === 'email') ??
|
||||
inputs[0];
|
||||
const passInput = inputs.find((i) => i.attributes('type') === 'password') ?? inputs[1];
|
||||
|
||||
await emailInput.setValue('ivan@example.ru');
|
||||
await passInput.setValue('secret123');
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(salesApi.salesLogin).toHaveBeenCalledWith('ivan@example.ru', 'secret123');
|
||||
expect(pushSpy).toHaveBeenCalledWith('/sales');
|
||||
});
|
||||
|
||||
it('при ошибке сервера показывает сообщение на русском', async () => {
|
||||
vi.mocked(salesApi.salesLogin).mockRejectedValue({
|
||||
response: { status: 401, data: { message: 'Неверный email или пароль.' } },
|
||||
});
|
||||
|
||||
const { wrapper } = await mountLogin();
|
||||
const inputs = wrapper.findAll('input');
|
||||
const emailInput = inputs.find((i) => i.attributes('type') === 'email') ?? inputs[0];
|
||||
const passInput = inputs.find((i) => i.attributes('type') === 'password') ?? inputs[1];
|
||||
|
||||
await emailInput.setValue('bad@example.ru');
|
||||
await passInput.setValue('wrong');
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const text = wrapper.text();
|
||||
// Должно появиться сообщение об ошибке на русском
|
||||
expect(
|
||||
text.includes('Неверный') ||
|
||||
text.includes('ошибка') ||
|
||||
text.includes('Ошибка') ||
|
||||
text.includes('пароль') ||
|
||||
wrapper.find('[data-testid="login-error"]').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('salesAuth.login вызывается с введёнными email и паролем', async () => {
|
||||
vi.mocked(salesApi.salesLogin).mockResolvedValue({
|
||||
token: 'tok-xyz',
|
||||
user: { id: 2, name: 'Анна', email: 'anna@example.ru', role: 'head' as const },
|
||||
});
|
||||
|
||||
const { wrapper } = await mountLogin();
|
||||
const salesAuth = useSalesAuthStore();
|
||||
const loginSpy = vi.spyOn(salesAuth, 'login');
|
||||
|
||||
const inputs = wrapper.findAll('input');
|
||||
const emailInput = inputs.find((i) => i.attributes('type') === 'email') ?? inputs[0];
|
||||
const passInput = inputs.find((i) => i.attributes('type') === 'password') ?? inputs[1];
|
||||
|
||||
await emailInput.setValue('anna@example.ru');
|
||||
await passInput.setValue('pass4567');
|
||||
await wrapper.find('form').trigger('submit.prevent');
|
||||
await flushPromises();
|
||||
|
||||
expect(loginSpy).toHaveBeenCalledWith('anna@example.ru', 'pass4567');
|
||||
});
|
||||
});
|
||||
@@ -28,9 +28,15 @@ describe('stripChannelPrefix', () => {
|
||||
expect(stripChannelPrefix('Натяжные потолки')).toBe('Натяжные потолки');
|
||||
});
|
||||
|
||||
it('не трогает B4_/B0_/Bx_ — только B1/B2/B3', () => {
|
||||
expect(stripChannelPrefix('B4_other')).toBe('B4_other');
|
||||
expect(stripChannelPrefix('B0_zero')).toBe('B0_zero');
|
||||
it('убирает B6_/B8_ и любой B<цифры>_ (фикс: не только B1-3)', () => {
|
||||
expect(stripChannelPrefix('B6_78002000010')).toBe('78002000010');
|
||||
expect(stripChannelPrefix('B8_segment')).toBe('segment');
|
||||
expect(stripChannelPrefix('B4_other')).toBe('other');
|
||||
expect(stripChannelPrefix('B0_zero')).toBe('zero');
|
||||
expect(stripChannelPrefix('B10_multi')).toBe('multi');
|
||||
});
|
||||
|
||||
it('не трогает букву BX_ — только цифры', () => {
|
||||
expect(stripChannelPrefix('BX_unknown')).toBe('BX_unknown');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\External\SmtpLivenessProbe;
|
||||
|
||||
it('зелёный, когда сокет открылся и вернул баннер 220', function () {
|
||||
$probe = new SmtpLivenessProbe(fn () => "220 smtp.yandex.ru ESMTP\r\n");
|
||||
$r = $probe->check();
|
||||
expect($r->serviceKey)->toBe('email');
|
||||
expect($r->light)->toBe('green');
|
||||
});
|
||||
|
||||
it('красный, когда соединитель бросил (порт недоступен)', function () {
|
||||
$probe = new SmtpLivenessProbe(function () {
|
||||
throw new RuntimeException('Connection refused');
|
||||
});
|
||||
$r = $probe->check();
|
||||
expect($r->light)->toBe('red');
|
||||
expect($r->detail)->toContain('refused');
|
||||
});
|
||||
|
||||
it('красный, когда баннер не 220', function () {
|
||||
$probe = new SmtpLivenessProbe(fn () => "554 blocked\r\n");
|
||||
expect($probe->check()->light)->toBe('red');
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\External\Yandex360BalanceProvider;
|
||||
use App\Services\External\Yandex360BalanceStore;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/** Стаб хранилища ручного баланса. */
|
||||
function balanceStoreReturning(?float $balance): Yandex360BalanceStore
|
||||
{
|
||||
return new class($balance) extends Yandex360BalanceStore
|
||||
{
|
||||
public function __construct(private ?float $b) {}
|
||||
|
||||
public function get(): ?float
|
||||
{
|
||||
return $this->b;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('ok при заданном балансе', function () {
|
||||
$p = new Yandex360BalanceProvider(balanceStoreReturning(1464.31));
|
||||
$r = $p->fetch();
|
||||
expect($r->serviceKey)->toBe('email');
|
||||
expect($r->ok)->toBeTrue();
|
||||
expect($r->balance)->toBe(1464.31);
|
||||
});
|
||||
|
||||
it('fail, когда баланс не задан', function () {
|
||||
$p = new Yandex360BalanceProvider(balanceStoreReturning(null));
|
||||
$r = $p->fetch();
|
||||
expect($r->ok)->toBeFalse();
|
||||
expect($r->error)->toContain('не задан');
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\External\Yandex360BalanceStore;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('set сохраняет число, get возвращает float', function () {
|
||||
$store = new Yandex360BalanceStore;
|
||||
$store->set(1464.31);
|
||||
expect($store->get())->toBe(1464.31);
|
||||
});
|
||||
|
||||
it('get возвращает null, когда не задан', function () {
|
||||
expect((new Yandex360BalanceStore)->get())->toBeNull();
|
||||
});
|
||||
|
||||
it('status отдаёт balance + updated_at', function () {
|
||||
$store = new Yandex360BalanceStore;
|
||||
expect($store->status())->toMatchArray(['balance' => null, 'updated_at' => null]);
|
||||
$store->set(500.0);
|
||||
$st = $store->status();
|
||||
expect($st['balance'])->toBe(500.0);
|
||||
expect($st['updated_at'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('null очищает баланс', function () {
|
||||
$store = new Yandex360BalanceStore;
|
||||
$store->set(500.0);
|
||||
$store->set(null);
|
||||
expect($store->get())->toBeNull();
|
||||
expect($store->status()['balance'])->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Sales\SalesPeriodRange;
|
||||
use App\Services\Sales\SalesPeriodResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
// Заморозка времени: 15 июня 2026, 12:00 МСК
|
||||
beforeEach(function (): void {
|
||||
CarbonImmutable::setTestNow(
|
||||
CarbonImmutable::parse('2026-06-15 12:00:00', 'Europe/Moscow'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
// ─── kind=this ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('kind=this returns current month range (June 2026)', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve(['kind' => 'this']);
|
||||
|
||||
expect($range)->toBeInstanceOf(SalesPeriodRange::class);
|
||||
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-06-01 00:00:00');
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-30 23:59:59');
|
||||
});
|
||||
|
||||
// ─── kind=prev ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('kind=prev returns previous month range (May 2026)', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve(['kind' => 'prev']);
|
||||
|
||||
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-05-01 00:00:00');
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-05-31 23:59:59');
|
||||
});
|
||||
|
||||
// ─── kind=prev2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('kind=prev2 returns month-before-previous range (April 2026)', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve(['kind' => 'prev2']);
|
||||
|
||||
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-04-01 00:00:00');
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-04-30 23:59:59');
|
||||
});
|
||||
|
||||
// ─── kind=custom ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('kind=custom returns exact from/to bounds', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve([
|
||||
'kind' => 'custom',
|
||||
'from' => '2026-03-10',
|
||||
'to' => '2026-05-20',
|
||||
]);
|
||||
|
||||
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-03-10 00:00:00');
|
||||
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-05-20 23:59:59');
|
||||
});
|
||||
|
||||
it('kind=custom: monthsIn returns all three month-starts for Mar-May span', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve([
|
||||
'kind' => 'custom',
|
||||
'from' => '2026-03-10',
|
||||
'to' => '2026-05-20',
|
||||
]);
|
||||
|
||||
$months = $resolver->monthsIn($range);
|
||||
|
||||
expect($months)->toHaveCount(3);
|
||||
expect($months[0]->format('Y-m-d'))->toBe('2026-03-01');
|
||||
expect($months[1]->format('Y-m-d'))->toBe('2026-04-01');
|
||||
expect($months[2]->format('Y-m-d'))->toBe('2026-05-01');
|
||||
});
|
||||
|
||||
it('kind=custom: throws InvalidArgumentException when from > to', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
expect(fn () => $resolver->resolve([
|
||||
'kind' => 'custom',
|
||||
'from' => '2026-05-20',
|
||||
'to' => '2026-03-10',
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('kind=custom: throws InvalidArgumentException when from is missing', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
expect(fn () => $resolver->resolve([
|
||||
'kind' => 'custom',
|
||||
'to' => '2026-05-20',
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('kind=custom: throws InvalidArgumentException when to is missing', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
expect(fn () => $resolver->resolve([
|
||||
'kind' => 'custom',
|
||||
'from' => '2026-03-10',
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
// ─── monthsIn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('monthsIn for single-month "this" range returns one entry (June 2026)', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve(['kind' => 'this']);
|
||||
|
||||
$months = $resolver->monthsIn($range);
|
||||
|
||||
expect($months)->toHaveCount(1);
|
||||
expect($months[0]->format('Y-m-d'))->toBe('2026-06-01');
|
||||
});
|
||||
|
||||
// ─── timezone ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('range start carries Europe/Moscow timezone', function (): void {
|
||||
$resolver = new SalesPeriodResolver;
|
||||
$range = $resolver->resolve(['kind' => 'this']);
|
||||
|
||||
expect($range->start->timezoneName)->toBe('Europe/Moscow');
|
||||
});
|
||||
@@ -43,8 +43,13 @@ it('distributeForPlatform splits order so per-platform limits sum to the order',
|
||||
'sms+kw 2→1/1' => [['B2', 'B3'], 2, ['B2' => 1, 'B3' => 1]],
|
||||
// SMS without keyword (1 platform) — no split, full order
|
||||
'sms 7→7' => [['B3'], 7, ['B3' => 7]],
|
||||
// Edge: zero order
|
||||
'zero' => [['B1', 'B2', 'B3'], 0, ['B1' => 0, 'B2' => 0, 'B3' => 0]],
|
||||
// Zero-share platforms are OMITTED — the supplier cabinet rejects limit=0 ("Введите limit!").
|
||||
// A limit-1 site project (order=1, 3 platforms) must go to B1 only, not B1+0+0.
|
||||
'limit-1 site → B1 only' => [['B1', 'B2', 'B3'], 1, ['B1' => 1]],
|
||||
'limit-2 site → B1,B2 only' => [['B1', 'B2', 'B3'], 2, ['B1' => 1, 'B2' => 1]],
|
||||
'sms+kw 1 → B2 only' => [['B2', 'B3'], 1, ['B2' => 1]],
|
||||
// Edge: zero order → no platforms at all
|
||||
'zero' => [['B1', 'B2', 'B3'], 0, []],
|
||||
]);
|
||||
|
||||
it('distributeForPlatform always conserves the order (sum invariant)', function (int $order, int $count): void {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\SupplierProjectName;
|
||||
|
||||
/**
|
||||
* Серверный аналог фронтового stripChannelPrefix (resources/js/composables/projectName.ts).
|
||||
* Поставщик префиксует имена проектов кодом канала-провайдера (B1_/B2_/B3_/B6_/B8_/B<N>_).
|
||||
* Клиенту этот префикс показывать нельзя — он раскрывает нашу схему каналов/перекупа.
|
||||
* Срезаем ЛЮБОЙ B<цифры>_ в начале строки (не только B1/B2/B3), но не букву (BX_) и не середину.
|
||||
*/
|
||||
test('срезает B1_/B2_/B3_ префикс', function () {
|
||||
expect(SupplierProjectName::strip('B1_73912557675 [35]'))->toBe('73912557675 [35]');
|
||||
expect(SupplierProjectName::strip('B2_krk-finance.ru/cabinet/auth [24]'))->toBe('krk-finance.ru/cabinet/auth [24]');
|
||||
expect(SupplierProjectName::strip('B3_kras.vashinvestor.ru [23]'))->toBe('kras.vashinvestor.ru [23]');
|
||||
});
|
||||
|
||||
test('срезает B6_/B8_ и любой B<цифры>_ (главный фикс — не только B1-3)', function () {
|
||||
expect(SupplierProjectName::strip('B6_78002000010'))->toBe('78002000010');
|
||||
expect(SupplierProjectName::strip('B8_segment-list'))->toBe('segment-list');
|
||||
expect(SupplierProjectName::strip('B4_other'))->toBe('other');
|
||||
expect(SupplierProjectName::strip('B0_zero'))->toBe('zero');
|
||||
expect(SupplierProjectName::strip('B10_multi'))->toBe('multi');
|
||||
});
|
||||
|
||||
test('case-insensitive: b1_/b6_ тоже срезает', function () {
|
||||
expect(SupplierProjectName::strip('b1_test'))->toBe('test');
|
||||
expect(SupplierProjectName::strip('b6_demo'))->toBe('demo');
|
||||
});
|
||||
|
||||
test('не трогает букву BX_ (только цифры) и имя без префикса', function () {
|
||||
expect(SupplierProjectName::strip('BX_unknown'))->toBe('BX_unknown');
|
||||
expect(SupplierProjectName::strip('Натяжные потолки'))->toBe('Натяжные потолки');
|
||||
expect(SupplierProjectName::strip('okna.ru'))->toBe('okna.ru');
|
||||
});
|
||||
|
||||
test('не трогает префикс внутри строки — только в начале', function () {
|
||||
expect(SupplierProjectName::strip('foo B1_bar'))->toBe('foo B1_bar');
|
||||
});
|
||||
|
||||
test('null сохраняет null, пустая строка — пустая (контракт API не ломаем)', function () {
|
||||
expect(SupplierProjectName::strip(null))->toBeNull();
|
||||
expect(SupplierProjectName::strip(''))->toBe('');
|
||||
});
|
||||
@@ -2273,3 +2273,19 @@ srv
|
||||
поставщиковых
|
||||
пулер
|
||||
пуло
|
||||
|
||||
gzk
|
||||
noeviction
|
||||
автобэкапы
|
||||
ВТБ
|
||||
генерится
|
||||
деплоить
|
||||
затыков
|
||||
локал
|
||||
локалку
|
||||
ОКПО
|
||||
онли
|
||||
преflight
|
||||
Сбере
|
||||
синхрона
|
||||
golive
|
||||
|
||||
@@ -2,7 +2,92 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.57, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.60, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.60 (2026-06-30) — Портал отдела продаж: таблица Sanctum-токенов
|
||||
|
||||
Продолжение фичи «Портал отдела продаж» (Task 0.3). Добавлена таблица
|
||||
`personal_access_tokens` (Sanctum) — раньше в проекте её не было, т.к. основной
|
||||
кабинет на SPA cookie-auth. Портал продаж (guard `sales`) использует Bearer-токены,
|
||||
поэтому таблица нужна.
|
||||
|
||||
Миграция: `app/database/migrations/2026_07_01_100001_create_personal_access_tokens_table.php`.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`personal_access_tokens`** — стандартная Sanctum-таблица: `tokenable_type/tokenable_id`
|
||||
(morphs), `name`, `token VARCHAR(64) UNIQUE`, `abilities`, `last_used_at`, `expires_at` (index),
|
||||
`created_at/updated_at`. DDL через `pgsql_supplier` (на проде дефолтная роль без CREATE).
|
||||
- **GRANTs для `crm_admin_user`** (идемпотентный DO-блок): SELECT/INSERT/UPDATE/DELETE на
|
||||
`personal_access_tokens` + USAGE/SELECT на `personal_access_tokens_id_seq`. Вся зона
|
||||
`/api/sales` (включая логин и проверку токена) работает через admin-db (`crm_admin_user`),
|
||||
поэтому Sanctum читает/пишет токены под этой ролью.
|
||||
|
||||
**Счётчики:** +1 таблица (системная, без RLS); +1 unique-индекс (`token`), +1 индекс (`expires_at`).
|
||||
RLS-политик — **без изменений**.
|
||||
|
||||
## v8.59 (2026-06-30) — Портал отдела продаж: 5 системных таблиц
|
||||
|
||||
Начало фичи «Портал отдела продаж» (Task 0.1). Пять таблиц SaaS-уровня (без RLS,
|
||||
без `tenant_id`) — фильтрация по владельцу происходит в коде приложения. Все операции
|
||||
проходят через соединение `pgsql_admin` (роль `crm_admin_user`).
|
||||
|
||||
Миграция: `app/database/migrations/2026_07_01_100000_create_sales_portal_tables.php`.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`sales_tariffs`** — каталог тарифных экземпляров. Поля: `name`, `kind` CHECK
|
||||
(`topup_step` | `percent_oborot` | `fix_per_client`), `params JSONB`, `is_active BOOLEAN DEFAULT TRUE`,
|
||||
`id/created_at/updated_at`.
|
||||
- **`sales_users`** — аккаунты менеджеров и руководителей отдела продаж. Поля: `name`,
|
||||
`email UNIQUE`, `password`, `role` CHECK (`manager` | `head`), `is_active`, `base_salary_rub DECIMAL(12,2)`
|
||||
(оклад), `current_tariff_id → sales_tariffs`, `created_by → sales_users` (самоссылочный FK).
|
||||
- **`sales_client_assignments`** — привязка «один менеджер на клиента» (UNIQUE `tenant_id`).
|
||||
Поля: `sales_user_id → sales_users`, `tenant_id UNIQUE → tenants`, `tariff_id → sales_tariffs`,
|
||||
`tariff_kind`, `tariff_params JSONB` — снимок тарифа на момент привязки.
|
||||
Индекс `idx_sca_sales_user (sales_user_id)`.
|
||||
- **`sales_attachment_requests`** — заявки менеджера на привязку клиента. Поля: `sales_user_id`,
|
||||
`login_input`, `tenant_id → tenants (nullable)`, `status` CHECK (`pending` | `approved` | `rejected` |
|
||||
`not_found`), `comment`, `decided_by → sales_users`, `decided_at`.
|
||||
Индекс `idx_sar_status (status)`.
|
||||
- **`sales_payouts`** — append-only журнал выплат. Поля: `sales_user_id`, `amount_rub DECIMAL(12,2)
|
||||
CHECK > 0`, `paid_on DATE`, `comment`, `created_by → sales_users`.
|
||||
Индекс `idx_payout_user (sales_user_id)`.
|
||||
Append-only гарантируется триггером `trg_sales_payouts_no_mutate` (BEFORE UPDATE OR DELETE)
|
||||
на функции `sales_payouts_no_mutate()` — RAISE EXCEPTION при любой попытке изменить/удалить строку.
|
||||
- **GRANTs для `crm_admin_user`** (идемпотентный DO-блок с проверкой `pg_roles`):
|
||||
SELECT/INSERT/UPDATE на четырёх таблицах; SELECT/INSERT на `sales_payouts`;
|
||||
USAGE/SELECT на всех последовательностях схемы public.
|
||||
|
||||
**Счётчики:** +5 таблиц (SaaS-level, без RLS); +3 явных индекса (`idx_sca_sales_user`,
|
||||
`idx_sar_status`, `idx_payout_user`); +1 функция (`sales_payouts_no_mutate`);
|
||||
+1 триггер (`trg_sales_payouts_no_mutate`, BEFORE UPDATE OR DELETE).
|
||||
RLS-политик — **без изменений**.
|
||||
|
||||
## v8.58 (2026-06-28) — Автоподбор конкурентов: таблица autopodbor_sources
|
||||
|
||||
Третья таблица фичи «Автоподбор конкурентов». Хранит источники конкурента — сайты и телефоны,
|
||||
извлечённые в рамках исследовательного прогона.
|
||||
|
||||
Миграция: `app/database/migrations/2026_06_28_100200_create_autopodbor_sources.php`.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`autopodbor_sources`** — таблица источников конкурента. Поля:
|
||||
- `signal_type VARCHAR(8)` — тип сигнала: `site` (сайт) | `call` (телефон).
|
||||
- `identifier VARCHAR(255)` — голова домена (для site) или номер `7XXXXXXXXXX` (для call).
|
||||
- `phone_kind VARCHAR(12) NULLABLE` — уточнение телефона: `real` | `substitute` | NULL (для site).
|
||||
- `provenance_url VARCHAR(500) NULLABLE` — URL страницы-источника.
|
||||
- `provenance_label VARCHAR(255) NULLABLE` — человекочитаемая метка источника.
|
||||
- `dedup_key VARCHAR(255)` — ключ дедупликации.
|
||||
- `created_project_id BIGINT NULLABLE` — FK → `projects` ON DELETE SET NULL.
|
||||
- `created_at TIMESTAMPTZ DEFAULT NOW()`.
|
||||
- **FK:** `tenant_id` → `tenants` ON DELETE CASCADE; `competitor_id` → `autopodbor_competitors` ON DELETE CASCADE;
|
||||
`study_run_id` → `autopodbor_runs` ON DELETE CASCADE; `created_project_id` → `projects` ON DELETE SET NULL.
|
||||
- **UNIQUE** `autopodbor_source_dedup` `(competitor_id, dedup_key)` — исключает дубли источника внутри конкурента.
|
||||
- **INDEX** `(tenant_id, competitor_id)` — быстрая выборка источников конкурента.
|
||||
- **RLS** `tenant_isolation` — `USING (tenant_id = current_setting('app.current_tenant_id')::bigint)`;
|
||||
`ENABLE ROW LEVEL SECURITY` + `FORCE ROW LEVEL SECURITY`.
|
||||
|
||||
## v8.57 (2026-06-26) — RLS GUC hardening: NULLIF во всех политиках tenant_isolation (инцидент входа)
|
||||
|
||||
@@ -18,6 +103,7 @@ GUC `app.current_tenant_id` либо пуст (`''` → **22P02** invalid input
|
||||
|
||||
**Фикс.** Все **44** политики `tenant_isolation` приведены к
|
||||
`NULLIF(current_setting('app.current_tenant_id', true), '')::bigint`:
|
||||
|
||||
- флаг `, true` → нет 42704 (параметр не задан → NULL, не ошибка);
|
||||
- `NULLIF(..., '')` → нет 22P02 (пусто → NULL, не ошибка).
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-06-29T08:26:07.630Z
|
||||
Last updated: 2026-07-02T04:35:54.067Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 4 week(s) ago |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 5 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ⚠️ | 2354 episode(s) this month · observer-stop-hook NOT registered in .claude/settings.json Stop hook |
|
||||
| C5 Observer-coverage | ⚠️ | 0 episode(s) this month · observer-stop-hook NOT registered in .claude/settings.json Stop hook |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 17 chains in sync |
|
||||
|
||||
## Кто на посту (оборона М1–М6)
|
||||
@@ -37,9 +37,9 @@ Last updated: 2026-06-29T08:26:07.630Z
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 2354 episodes this month, 0 observer_error markers, 8 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 2354
|
||||
- Last /brain-retro: 33 day(s) ago
|
||||
- Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 0
|
||||
- Last /brain-retro: 36 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
@@ -48,16 +48,11 @@ Baseline дисциплины роутера (этап 2 router discipline overh
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| planning | 258 | 5.0% | 16.3% |
|
||||
| feature | 77 | 3.9% | 3.9% |
|
||||
| bugfix | 61 | 6.6% | 13.1% |
|
||||
| analysis | 50 | 6.0% | 2.0% |
|
||||
| cleanup | 3 | 0.0% | 0.0% |
|
||||
| refactor | 2 | 0.0% | 50.0% |
|
||||
| (no data) | 0 | 0% | 0% |
|
||||
|
||||
Router step distribution: 1: 1256, 2: 813, 3: 40, 5: 215
|
||||
Router step distribution: (empty)
|
||||
|
||||
Boundaries applied (ADR / границы): 33 of 2324 эпизодов (1.4%).
|
||||
Boundaries applied (ADR / границы): 0 of 0 эпизодов (0.0%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
@@ -69,16 +64,16 @@ Boundaries applied (ADR / границы): 33 of 2324 эпизодов (1.4%).
|
||||
|
||||
## Длинные сессии
|
||||
|
||||
Ни одной сессии с >50 ходов сегодня (UTC). ✅
|
||||
(нет данных)
|
||||
|
||||
## Стоимость месяца
|
||||
|
||||
| Компонент | Токены (in/out) | USD |
|
||||
|---|---|---|
|
||||
| Classifier (Sonnet 4.6) | 68479/251178 | $3.97 |
|
||||
| Classifier (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
|
||||
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
|
||||
| **Итого** | | **$3.97** |
|
||||
| **Итого** | | **$0.00** |
|
||||
|
||||
## Аномалии классификатора
|
||||
|
||||
@@ -91,7 +86,7 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## Reviewer: субагент vs fallback
|
||||
|
||||
0 эпизодов проверено из 2354.
|
||||
0 эпизодов проверено из 0.
|
||||
|
||||
## Reviewer findings
|
||||
|
||||
@@ -117,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 3440 | MsMpEng | 20.00ч | NaNч |
|
||||
| 21928 | Code | 10.83ч | NaNч |
|
||||
| 1212 | svchost | 5.68ч | NaNч |
|
||||
| 9812 | Code | 8.06ч | NaNч |
|
||||
| 3412 | MsMpEng | 7.06ч | NaNч |
|
||||
| 748 | claude | 1.95ч | NaNч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
|
||||