Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 726c682d2e | |||
| 7ea084d01f | |||
| 85c7c9b53c | |||
| 4ad2c065fc | |||
| 4afa228f15 | |||
| 2f9d7743ec | |||
| 94e5828fbc | |||
| 6841492226 | |||
| edf98d9ace | |||
| c90f721978 | |||
| 726aeb716a | |||
| 7a18dae0ca | |||
| 335bf4c3a8 | |||
| e2dfd22471 | |||
| edbfd3e993 | |||
| 4cab703b82 | |||
| 3a724fb8ef | |||
| 508d8cc1d5 | |||
| b04bb4ecf3 | |||
| e8e7332101 | |||
| 9f8ded5b77 | |||
| e01bcca751 | |||
| aa3bf3cbed | |||
| e3b58f2c2c | |||
| f606a06155 | |||
| b4ef5830e3 | |||
| 8e864bf96f | |||
| f30c6612c0 | |||
| 2ecc1d6115 | |||
| 02a8a90e4d | |||
| 67ea5d32b4 | |||
| fa7361364d | |||
| 69f8614abe | |||
| 9eaa9322dc | |||
| 1a92b70223 | |||
| 7ac9af7c79 | |||
| 1fd56e205b | |||
| c7e015a9ac | |||
| 11dcd04173 | |||
| c78b69fcaf | |||
| 9f013ec591 | |||
| 4fd4e390af | |||
| 4044885c3e | |||
| 9d0999d49a | |||
| b38fe0c875 | |||
| 1c72f6dec2 | |||
| d5c972c3f2 | |||
| 819d74292f | |||
| 2c876162d5 | |||
| 737d2e192b | |||
| 1b3158dd45 | |||
| a8aa79e75f | |||
| a17e72a52e | |||
| 08558df8ee | |||
| d6ffa0a6d0 | |||
| 1b809d6abc | |||
| 662ebd6e8b | |||
| 1b5316b2c8 | |||
| 7b23118856 | |||
| 347bc3a13b | |||
| 7efe9e3e83 | |||
| 77107c9cb8 | |||
| fbf982e12c | |||
| f9f86ca05f | |||
| f82596c527 |
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
// PreToolUse guard (Bash|PowerShell): блокирует ТОЛЬКО удаление/пересоздание
|
||||
// БОЕВОЙ базы/кластера Лидерры. Обычную работу (чтение, запросы, тесты на
|
||||
// отдельной базе, правки через приложение) НЕ трогает.
|
||||
//
|
||||
// Повод: 26.06.2026 параллельная сессия выполнила `yc managed-postgresql
|
||||
// database delete liderra` + recreate на боевом кластере → переналила схему со
|
||||
// старыми небезопасными RLS-политиками → вход в портал лёг. См. db/CHANGELOG_schema.md v8.57.
|
||||
//
|
||||
// Боевая база = Managed PG кластер c9q2cvtjpq3hgq6l0r96 (rw-endpoint *.mdb.yandexcloud.net).
|
||||
// Тест-база = отдельная liderra_testing (её сносить можно).
|
||||
//
|
||||
// Override владельца: маркер `PROD-DESTROY-OK` в самой команде ИЛИ env ALLOW_PROD_DB_DESTROY=1.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
let raw = '';
|
||||
try { raw = readFileSync(0, 'utf8'); } catch { /* нет stdin — пропускаем */ }
|
||||
|
||||
let cmd = '';
|
||||
try {
|
||||
const j = JSON.parse(raw || '{}');
|
||||
cmd = (j.tool_input && (j.tool_input.command ?? j.tool_input.script)) || '';
|
||||
} catch { /* не JSON — нечего проверять */ }
|
||||
|
||||
cmd = String(cmd);
|
||||
|
||||
// Явный override владельца — пропускаем.
|
||||
if (process.env.ALLOW_PROD_DB_DESTROY === '1' || /PROD-DESTROY-OK/.test(cmd)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const PROD_CLUSTER = 'c9q2cvtjpq3hgq6l0r96';
|
||||
|
||||
// Цель — именно ПРОД (а не liderra_testing): по cluster-id, по rw/managed-хосту,
|
||||
// либо по имени базы `liderra` как отдельному слову (не liderra_testing).
|
||||
const targetsProd =
|
||||
new RegExp(PROD_CLUSTER, 'i').test(cmd) ||
|
||||
/\bc-[a-z0-9]+\.(rw|ro)\.mdb\.yandexcloud\.net/i.test(cmd) ||
|
||||
/\bliderra\b(?!_)/i.test(cmd);
|
||||
|
||||
// Деструктив над управляемой БД/кластером.
|
||||
const clusterDelete = /managed-postgresql\s+cluster\s+delete/i.test(cmd); // снос кластера — всегда катастрофа
|
||||
const databaseDelete = /managed-postgresql\s+database\s+delete/i.test(cmd); // снос управляемой БД
|
||||
const dropDatabase = /\bdrop\s+database\b/i.test(cmd); // SQL DROP DATABASE
|
||||
|
||||
const destructive = clusterDelete || databaseDelete || dropDatabase;
|
||||
|
||||
// Снос кластера блокируем всегда; остальное — только если цель = прод.
|
||||
if (destructive && (clusterDelete || targetsProd)) {
|
||||
const reason =
|
||||
'ЗАБЛОКИРОВАНО (prod-db-guard): попытка удалить/пересоздать БОЕВУЮ базу/кластер Лидерры. ' +
|
||||
'Это снесёт портал (инцидент 26.06.2026). Боевая база = Managed PG кластер ' + PROD_CLUSTER + '. ' +
|
||||
'Для тестов используй ОТДЕЛЬНУЮ базу liderra_testing, не прод. ' +
|
||||
'Если это осознанное действие ВЛАДЕЛЬЦА — добавь в команду маркер PROD-DESTROY-OK ' +
|
||||
'или запусти с env ALLOW_PROD_DB_DESTROY=1.';
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason,
|
||||
},
|
||||
systemMessage: reason,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// SessionStart: указатель «где сейчас живая боевая база» — чтобы любая сессия
|
||||
// не путала актуальный кластер со старой rollback-копией на VM и не пыталась
|
||||
// её «пересобирать». Только инъекция контекста, ничего не блокирует.
|
||||
|
||||
const context = [
|
||||
'ОРИЕНТИР ПО БАЗЕ ЛИДЕРРЫ (важно перед любой работой с БД):',
|
||||
'- ЖИВАЯ боевая база = Yandex Managed PG, кластер c9q2cvtjpq3hgq6l0r96',
|
||||
' (rw-endpoint *.rw.mdb.yandexcloud.net:6432). Доступ — через app/.env',
|
||||
' (роли crm_app_user / crm_supplier_worker). Это ЕДИНСТВЕННЫЙ источник',
|
||||
' актуальных данных портала.',
|
||||
'- На прод-VM (127.0.0.1:5432) лежит СТАРАЯ rollback-копия (до переезда 26.06).',
|
||||
' НЕ путать с живой, НЕ менять там данные. `sudo -u postgres psql` на VM = старая копия.',
|
||||
'- Для тестов — ОТДЕЛЬНАЯ база liderra_testing (через php artisan migrate),',
|
||||
' НИКОГДА не прод `liderra`.',
|
||||
'- НИКОГДА не удалять/пересоздавать боевую базу/кластер',
|
||||
' (yc managed-postgresql database/cluster delete, DROP DATABASE liderra) —',
|
||||
' это снесёт портал (инцидент 26.06, см. db/CHANGELOG_schema.md v8.57).',
|
||||
' Хук prod-db-guard это блокирует; осознанный снос владельцем — маркер PROD-DESTROY-OK.',
|
||||
].join('\n');
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: context,
|
||||
},
|
||||
}));
|
||||
+21
-266
@@ -32,283 +32,38 @@
|
||||
"Bash(git push --force:*)",
|
||||
"Bash(git reset --hard:*)",
|
||||
"Bash(npm publish:*)",
|
||||
"Bash(yc managed-postgresql database delete:*)",
|
||||
"Bash(yc managed-postgresql cluster delete:*)",
|
||||
"PowerShell(Remove-Item:*-Recurse*)",
|
||||
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)"
|
||||
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)",
|
||||
"PowerShell(yc managed-postgresql database delete:*)",
|
||||
"PowerShell(yc managed-postgresql cluster delete:*)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-tool-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-memory-coverage.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-branch-switch.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-before-push.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-router-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-powershell-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-normative-content-rules.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-real-test-verifier.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-self-debrief-detector.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/askuser-cosmetic-detector.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-mcp-classification.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-read-path-deny.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-record.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-subagent-return-scanner.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-stop-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-coverage-verify.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-todowrite-skill-verifier.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/cost-stop-hook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-prompt-injection.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-embedding-warmup.mjs",
|
||||
"timeout": 30
|
||||
"command": "node .claude/hooks/prod-db-pointer.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node .claude/hooks/prod-db-guard.mjs",
|
||||
"timeout": 10,
|
||||
"statusMessage": "prod-db-guard"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,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. **NB:** cross-ref версии CLAUDE.md в Pravila/PSR/Tooling указывают 2.46 — синхронизация квинтета на 2.47 — отдельный follow-up.
|
||||
**Версия:** 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.
|
||||
@@ -241,11 +241,11 @@ trivy image liderra:latest
|
||||
- `ЭТАЛОН.md` (корень репо) — локальная dev-версия (git/окружение/временное/демо).
|
||||
- `ПИЛОТ.md` (корень репо) — боевая интернет-версия liderra.ru (доступ/HTTPS/сервер/БД/безопасность/YC Lockbox).
|
||||
|
||||
**Последняя продуктовая фича:** определение региона лида по телефону + каскадная маршрутизация (DaData → реестр Россвязи → tag-fallback) — на проде, включена на 100%.
|
||||
**Последняя продуктовая фича:** разблокировка смены источника проекта без потери лидов — матч поставщиковых лидов по слепку `project_routing_snapshots` (флаг `routing_match_by_snapshot`), Эпик 4 онлайн-заморозка 18:00→00:00 + `FlushDeferredOnlineSyncJob` (00:05 МСК), экран «Вечерняя заливка» (`supplier_sync_runs`) и дружелюбный тумблер управления флагом в админке «Интеграция с поставщиком». На проде liderra.ru (26.06.2026), флаг **ВКЛЮЧЁН**, идёт суточное наблюдение. Откат — тумблер в ВЫКЛ.
|
||||
|
||||
**Полный журнал фаз и работ** (что и когда делалось, включая историю «мозга») — в [docs/CHANGELOG_claude_md.md](docs/CHANGELOG_claude_md.md).
|
||||
|
||||
**P0-блокер:** **Б-1** (реквизиты юр. лица, ждут регистрации ООО). От него зависят Диз-3, DO-2, DO-4.
|
||||
**Б-1 (юр. лицо) — закрыт:** ИП **зарегистрирован** (НЕ ООО), договор с **ЮKassa** готов — осталось только подписать; после подписи включается онлайн-оплата (флаг `billing_yookassa_enabled`). Зависевшие Диз-3, DO-2, DO-4 — разблокированы. Источник истины — память `project-legal-entity-ip-yookassa-2026-06-25` (25.06.2026).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -84,6 +84,12 @@ MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
SUPPORT_EMAIL=support@liderra.ru
|
||||
JIVO_WIDGET_ID=
|
||||
JIVO_BOT_WEBHOOK_SECRET=
|
||||
JIVO_BOT_OUTBOUND_URL=
|
||||
JIVO_BOT_TOKEN=
|
||||
JIVO_BOT_TOURS_ENABLED=false
|
||||
YANDEX_GPT_API_KEY=
|
||||
YANDEX_GPT_FOLDER_ID=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -101,13 +101,15 @@ final class AuditRebuildChain extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Disable BEFORE triggers (audit_block_mutation blocks UPDATE).
|
||||
// Use session-level SET so it works even inside a wrapping transaction
|
||||
// (e.g. DatabaseTransactions in tests). Reset in finally.
|
||||
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'replica'");
|
||||
|
||||
try {
|
||||
$totalUpdated = 0;
|
||||
// Пересчёт цепочки = UPDATE по append-only таблицам. Вместо superuser-параметра
|
||||
// session_replication_role (недоступен в Managed PG — Путь А) используем метку
|
||||
// app.audit_rebuild='on', которую чтит триггер audit_block_mutation. SET LOCAL
|
||||
// внутри транзакции — Odyssey-safe: метка живёт ровно на время пересчёта и
|
||||
// сбрасывается на commit. В тестах (DatabaseTransactions + SharesSupplierPdo)
|
||||
// это savepoint внутри внешней транзакции — метка применяется ко всем UPDATE.
|
||||
$totalUpdated = 0;
|
||||
DB::connection('pgsql_supplier')->transaction(function () use ($partition, $partitionClause, $rowExpr, $fromId, &$totalUpdated) {
|
||||
DB::connection('pgsql_supplier')->statement("SET LOCAL app.audit_rebuild = 'on'");
|
||||
|
||||
if ($partitionClause === 'PARTITION BY tenant_id') {
|
||||
// Per-tenant rebuild — separate scope iteration per tenant.
|
||||
@@ -128,14 +130,12 @@ final class AuditRebuildChain extends Command
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// BYPASSRLS-таблицы (auth_log, saas_admin_audit_log) — global scope.
|
||||
// global scope (auth_log, saas_admin_audit_log).
|
||||
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
|
||||
} finally {
|
||||
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'origin'");
|
||||
}
|
||||
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
|
||||
|
||||
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use App\Support\Help\HelpArticleParser;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Переиндексация базы знаний бота из resources/help/*.md (спека §3).
|
||||
* Полная перезаливка в транзакции: удалённые статьи исчезают, новые появляются.
|
||||
* Ночной schedule 04:30 + ручной запуск при срочном обновлении инструкции.
|
||||
*/
|
||||
class HelpRebuildKnowledgeCommand extends Command
|
||||
{
|
||||
protected $signature = 'help:rebuild-knowledge';
|
||||
|
||||
protected $description = 'Перечитать статьи resources/help и обновить knowledge_chunks';
|
||||
|
||||
public function handle(HelpArticleParser $parser): int
|
||||
{
|
||||
$dir = resource_path('help');
|
||||
$files = glob($dir.'/*.md') ?: [];
|
||||
if ($files === []) {
|
||||
$this->error("В {$dir} нет статей *.md — база знаний осталась прежней.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$articles = [];
|
||||
foreach ($files as $file) {
|
||||
$articles[] = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($articles): void {
|
||||
KnowledgeChunk::query()->delete();
|
||||
foreach ($articles as $article) {
|
||||
foreach ($article->chunks as $i => $chunk) {
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => $article->sourcePath,
|
||||
'title' => $article->title,
|
||||
'tour' => $article->tour,
|
||||
'topics' => $article->topics,
|
||||
'chunk_index' => $i,
|
||||
'content' => $chunk,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('Проиндексировано статей: %d.', count($articles)));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Dashboard\SupplyReconciliation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin «Командный центр» — read-only агрегаты для дашборда.
|
||||
* Под группой ['saas-admin','admin-db'] → cross-tenant через pgsql_admin.
|
||||
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
|
||||
*/
|
||||
class AdminDashboardController extends Controller
|
||||
{
|
||||
/** Период из query: today | 7d | 30d | 60d | 90d (дефолт 7d). */
|
||||
private function periodStart(Request $request): Carbon
|
||||
{
|
||||
return match ((string) $request->query('period', '7d')) {
|
||||
'today' => now()->startOfDay(),
|
||||
'30d' => now()->subDays(30),
|
||||
'60d' => now()->subDays(60),
|
||||
'90d' => now()->subDays(90),
|
||||
default => now()->subDays(7),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard — сводка L1 (плитки Финансы + Здоровье). */
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$from = $this->periodStart($request);
|
||||
|
||||
return response()->json([
|
||||
'period' => (string) $request->query('period', '7d'),
|
||||
'finance' => $this->financeTile($from),
|
||||
'health' => $this->healthTile(),
|
||||
'leads' => $this->leadsTile(),
|
||||
'supply' => $this->supplyTile(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function financeTile(Carbon $from): array
|
||||
{
|
||||
$topups = (float) DB::table('balance_transactions')
|
||||
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
|
||||
$charges = (float) DB::table('balance_transactions')
|
||||
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub');
|
||||
$active = DB::table('tenants')->where('status', 'active')->whereNull('deleted_at')->count();
|
||||
$newClients = DB::table('tenants')->where('created_at', '>=', $from)->whereNull('deleted_at')->count();
|
||||
$negative = DB::table('tenants')->whereNull('deleted_at')->where('balance_rub', '<', 0)->count();
|
||||
|
||||
return [
|
||||
'topups_rub' => (string) $topups,
|
||||
'charges_rub' => (string) abs($charges),
|
||||
'active_clients' => $active,
|
||||
'new_clients' => $newClients,
|
||||
'negative_balance_count' => $negative,
|
||||
'light' => $negative > 0 ? 'red' : 'green',
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/finance — детали Финансов (L2). */
|
||||
public function finance(Request $request): JsonResponse
|
||||
{
|
||||
$from = $this->periodStart($request);
|
||||
|
||||
$topups = (float) DB::table('balance_transactions')
|
||||
->where('type', 'topup')->where('created_at', '>=', $from)->sum('amount_rub');
|
||||
$charges = abs((float) DB::table('balance_transactions')
|
||||
->where('type', 'lead_charge')->where('created_at', '>=', $from)->sum('amount_rub'));
|
||||
|
||||
// «Требуют внимания»: баланс < 0 (по возрастанию — самые глубокие минусы сверху).
|
||||
$attention = DB::table('tenants')->whereNull('deleted_at')
|
||||
->where('balance_rub', '<', 0)
|
||||
->orderBy('balance_rub')
|
||||
->limit(20)
|
||||
->get(['id', 'subdomain', 'organization_name', 'balance_rub', 'balance_leads'])
|
||||
->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'subdomain' => $t->subdomain,
|
||||
'organization_name' => $t->organization_name,
|
||||
'balance_rub' => (string) $t->balance_rub,
|
||||
'state' => 'negative',
|
||||
]);
|
||||
|
||||
// Топ по обороту: сумма пополнений за период.
|
||||
$top = DB::table('balance_transactions')
|
||||
->join('tenants', 'tenants.id', '=', 'balance_transactions.tenant_id')
|
||||
->where('balance_transactions.type', 'topup')
|
||||
->where('balance_transactions.created_at', '>=', $from)
|
||||
->whereNull('tenants.deleted_at')
|
||||
->groupBy('tenants.id', 'tenants.organization_name')
|
||||
->orderByRaw('SUM(balance_transactions.amount_rub) DESC')
|
||||
->limit(10)
|
||||
->get([
|
||||
'tenants.id',
|
||||
'tenants.organization_name',
|
||||
DB::raw('SUM(balance_transactions.amount_rub) AS topped_rub'),
|
||||
])
|
||||
->map(fn ($r) => [
|
||||
'id' => (int) $r->id,
|
||||
'organization_name' => $r->organization_name,
|
||||
'topped_rub' => (string) $r->topped_rub,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'period' => (string) $request->query('period', '7d'),
|
||||
'kpi' => [
|
||||
'topups_rub' => (string) $topups,
|
||||
'charges_rub' => (string) $charges,
|
||||
'net_inflow_rub' => (string) ($topups - $charges),
|
||||
'negative_balance_count' => $attention->count(),
|
||||
],
|
||||
'attention' => $attention,
|
||||
'top_by_turnover' => $top,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/health — 6 подсистем эксплуатации (L2). */
|
||||
public function health(): JsonResponse
|
||||
{
|
||||
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
|
||||
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
|
||||
$lastReconcile = DB::table('supplier_csv_reconcile_log')->orderByDesc('id')->first();
|
||||
$unresolvedWebhooks = DB::table('failed_webhook_jobs')->whereNull('resolved_at')->count();
|
||||
$inc = $this->incidentCounts();
|
||||
$staleHeartbeat = DB::table('scheduler_heartbeats')->where('consecutive_failures', '>', 0)->count();
|
||||
|
||||
$jobsLight = ($failedJobs > 0 || $inc['auto_job_24h'] > 0) ? 'red' : 'green';
|
||||
$jobsDetail = $inc['auto_job_24h'] > 0
|
||||
? $inc['auto_job_24h'].' повторяющихся ошибок джоб за сутки'
|
||||
: $failedJobs.' упавших за сутки';
|
||||
|
||||
$subsystems = [
|
||||
['key' => 'queues', 'light' => $jobsLight, 'detail' => $jobsDetail],
|
||||
['key' => 'scheduler', 'light' => $staleHeartbeat > 0 ? 'red' : 'green',
|
||||
'detail' => $staleHeartbeat > 0 ? $staleHeartbeat.' задач с пропусками' : 'по расписанию'],
|
||||
['key' => 'supplier_sync', 'light' => ($lastSync && in_array($lastSync->status, ['failed', 'aborted'], true)) ? 'red' : 'green',
|
||||
'detail' => 'последний: '.($lastSync->status ?? 'нет')],
|
||||
['key' => 'csv_drift', 'light' => ($lastReconcile && $lastReconcile->status === 'drift_alert') ? 'red' : 'green',
|
||||
'detail' => 'статус: '.($lastReconcile->status ?? 'нет')],
|
||||
['key' => 'webhooks', 'light' => $unresolvedWebhooks > 0 ? 'amber' : 'green',
|
||||
'detail' => $unresolvedWebhooks.' неразобранных'],
|
||||
['key' => 'incidents', 'light' => $inc['real'] > 0 ? 'red' : 'green',
|
||||
'detail' => $inc['real'].' открытых (реальных)'],
|
||||
];
|
||||
|
||||
$order = ['green' => 0, 'amber' => 1, 'red' => 2];
|
||||
$overall = collect($subsystems)->sortByDesc(fn ($s) => $order[$s['light']])->first()['light'];
|
||||
|
||||
return response()->json(['subsystems' => $subsystems, 'overall_light' => $overall]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Счётчики инцидентов с разделением: РЕАЛЬНЫЕ (заведённые человеком/РКН) vs
|
||||
* АВТО-ошибки джоб ('Автоматически: persistent exception job=…'), которые
|
||||
* копятся и сами не закрываются. Для здоровья считаем реальные + свежие авто.
|
||||
*
|
||||
* @return array{real:int,auto_job_24h:int}
|
||||
*/
|
||||
private function incidentCounts(): array
|
||||
{
|
||||
$real = DB::table('incidents_log')->whereNull('resolved_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('summary')->orWhere('summary', 'not like', 'Автоматически:%');
|
||||
})
|
||||
->count();
|
||||
|
||||
$autoJob24h = DB::table('incidents_log')->whereNull('resolved_at')
|
||||
->where('summary', 'like', 'Автоматически:%')
|
||||
->where('detected_at', '>=', now()->subDay())
|
||||
->count();
|
||||
|
||||
return ['real' => $real, 'auto_job_24h' => $autoJob24h];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function healthTile(): array
|
||||
{
|
||||
$inc = $this->incidentCounts();
|
||||
$lastSync = DB::table('supplier_sync_runs')->orderByDesc('id')->first();
|
||||
$failedJobs = DB::table('failed_jobs')->where('failed_at', '>=', now()->subDay())->count();
|
||||
|
||||
$light = 'green';
|
||||
if ($inc['real'] > 0 || $failedJobs > 0 || $inc['auto_job_24h'] > 0
|
||||
|| ($lastSync !== null && in_array($lastSync->status, ['failed', 'aborted'], true))) {
|
||||
$light = 'red';
|
||||
}
|
||||
|
||||
return [
|
||||
'light' => $light,
|
||||
'open_incidents' => $inc['real'],
|
||||
'job_errors_24h' => $inc['auto_job_24h'],
|
||||
'failed_jobs_24h' => $failedJobs,
|
||||
'last_sync_status' => $lastSync->status ?? 'none',
|
||||
'last_sync_at' => $lastSync->finished_at ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// === Этап 2: Лиды ===
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function leadsMetrics(): array
|
||||
{
|
||||
$todayStart = now('Europe/Moscow')->startOfDay();
|
||||
|
||||
// Доставлено = реально созданные сегодня сделки у клиентов (не тест, не удал.).
|
||||
$deliveredToday = DB::table('deals')
|
||||
->where('received_at', '>=', $todayStart)
|
||||
->where('is_test', false)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
// Получено от поставщика сегодня.
|
||||
$receivedToday = DB::table('supplier_leads')->where('received_at', '>=', $todayStart)->count();
|
||||
// В очереди на распределение прямо сейчас.
|
||||
$unrouted = DB::table('supplier_leads')->whereNull('processed_at')->count();
|
||||
// Зависшие = не распределены дольше 4 часов (порог cron leads:escalate-stale).
|
||||
$stuck = DB::table('supplier_leads')
|
||||
->whereNull('processed_at')
|
||||
->where('received_at', '<', now()->subHours(4))
|
||||
->count();
|
||||
|
||||
$light = 'green';
|
||||
if ($stuck > 0) {
|
||||
$light = 'red';
|
||||
} elseif ($unrouted > 0) {
|
||||
$light = 'amber';
|
||||
}
|
||||
|
||||
return [
|
||||
'light' => $light,
|
||||
'delivered_today' => $deliveredToday,
|
||||
'received_today' => $receivedToday,
|
||||
'stuck' => $stuck,
|
||||
'unrouted' => $unrouted,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function leadsTile(): array
|
||||
{
|
||||
$m = $this->leadsMetrics();
|
||||
|
||||
return [
|
||||
'light' => $m['light'],
|
||||
'delivered_today' => $m['delivered_today'],
|
||||
'received_today' => $m['received_today'],
|
||||
'stuck' => $m['stuck'],
|
||||
'unrouted' => $m['unrouted'],
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/leads — KPI распределения лидов (L2). */
|
||||
public function leads(): JsonResponse
|
||||
{
|
||||
$m = $this->leadsMetrics();
|
||||
|
||||
return response()->json([
|
||||
'light' => $m['light'],
|
||||
'kpi' => [
|
||||
'delivered_today' => $m['delivered_today'],
|
||||
'received_today' => $m['received_today'],
|
||||
'stuck' => $m['stuck'],
|
||||
'unrouted' => $m['unrouted'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// === Этап 2: Заказ у поставщика ===
|
||||
|
||||
/**
|
||||
* Сырьё для сверки заказа: спрос (последний снимок) + факт (supplier_projects).
|
||||
* Плюс ПОЛНАЯ картина у поставщика (все активные заказы), чтобы не выглядело
|
||||
* занижено: сверка идёт только по группам последнего снимка, а заказов больше.
|
||||
*
|
||||
* @return array{snapshot_date:?string,total_orders:int,total_limit:int,result:array{groups:list<array<string,mixed>>,totals:array<string,int>}}
|
||||
*/
|
||||
private function supplyReconciliation(): array
|
||||
{
|
||||
/** @var string|null $latest */
|
||||
$latest = DB::table('project_routing_snapshots')->max('snapshot_date');
|
||||
|
||||
$demand = [];
|
||||
if ($latest !== null) {
|
||||
$rows = DB::table('project_routing_snapshots')
|
||||
->where('snapshot_date', $latest)
|
||||
->groupBy('signal_type', 'signal_identifier')
|
||||
->select(
|
||||
'signal_type',
|
||||
'signal_identifier',
|
||||
DB::raw('SUM(daily_limit) AS demand'),
|
||||
DB::raw('MAX(daily_limit) AS max_limit'),
|
||||
)
|
||||
->get();
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$demand[] = [
|
||||
'signal_type' => (string) $r->signal_type,
|
||||
'identifier' => (string) $r->signal_identifier,
|
||||
'demand' => (int) $r->demand,
|
||||
'max_limit' => (int) $r->max_limit,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string,int> $orderedByKey */
|
||||
$orderedByKey = DB::table('supplier_projects')
|
||||
->groupBy('signal_type', 'unique_key')
|
||||
->select('signal_type', 'unique_key', DB::raw('SUM(current_limit) AS ordered'))
|
||||
->get()
|
||||
->mapWithKeys(fn ($r) => [$r->signal_type.'|'.$r->unique_key => (int) $r->ordered])
|
||||
->all();
|
||||
|
||||
return [
|
||||
'snapshot_date' => $latest,
|
||||
'total_orders' => (int) DB::table('supplier_projects')->where('current_limit', '>', 0)->count(),
|
||||
'total_limit' => (int) DB::table('supplier_projects')->sum('current_limit'),
|
||||
'result' => SupplyReconciliation::build($demand, $orderedByKey),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function supplyTile(): array
|
||||
{
|
||||
$rec = $this->supplyReconciliation();
|
||||
$totals = $rec['result']['totals'];
|
||||
|
||||
return [
|
||||
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
|
||||
'demand' => $totals['demand'],
|
||||
'formula' => $totals['formula'],
|
||||
'ordered' => $totals['ordered'],
|
||||
'mismatches' => $totals['mismatches'],
|
||||
'total_orders' => $rec['total_orders'],
|
||||
'total_limit' => $rec['total_limit'],
|
||||
'snapshot_date' => $rec['snapshot_date'],
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/supply — заказ у поставщика по группам (L2). */
|
||||
public function supply(): JsonResponse
|
||||
{
|
||||
$rec = $this->supplyReconciliation();
|
||||
$totals = $rec['result']['totals'];
|
||||
|
||||
return response()->json([
|
||||
'snapshot_date' => $rec['snapshot_date'],
|
||||
'light' => $totals['mismatches'] > 0 ? 'red' : 'green',
|
||||
'totals' => $totals,
|
||||
'total_orders' => $rec['total_orders'],
|
||||
'total_limit' => $rec['total_limit'],
|
||||
'groups' => $rec['result']['groups'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Support\RussianRegions;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -225,6 +226,49 @@ final class AdminSupplierIntegrationController extends Controller
|
||||
return response()->json(['mode' => $data['mode']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
|
||||
* GET — текущее состояние ВКЛ/ВЫКЛ для переключателя в админке.
|
||||
*/
|
||||
public function getSourceEditFlag(): JsonResponse
|
||||
{
|
||||
return response()->json(['enabled' => SystemSettings::bool('routing_match_by_snapshot', false)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — включить/выключить разблокировку смены источника (матч по слепку).
|
||||
* Пишет в system_settings (type=bool) + audit-журнал; основание не требуется
|
||||
* (дружелюбный тумблер для владельца, в отличие от общего edit-flow §settings).
|
||||
*/
|
||||
public function setSourceEditFlag(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'enabled' => ['required', 'boolean'],
|
||||
]);
|
||||
$enabled = (bool) $data['enabled'];
|
||||
|
||||
$prev = DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value');
|
||||
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'routing_match_by_snapshot'],
|
||||
['value' => $enabled ? 'true' : 'false', 'type' => 'bool', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $this->resolveAdminUserId($request, 'supplier-integration@system.stub', 'Supplier Integration Stub'),
|
||||
'action' => 'supplier_integration.source_edit_flag_set',
|
||||
'target_type' => 'system_setting',
|
||||
'target_id' => null,
|
||||
'payload_before' => $prev !== null ? ['enabled' => $prev] : null,
|
||||
'payload_after' => ['enabled' => $enabled ? 'true' : 'false'],
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
'requires_approval' => false,
|
||||
]);
|
||||
|
||||
return response()->json(['enabled' => $enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 4 Task 2: список supplier_projects + кто заказывал (через pivot →
|
||||
* projects → tenants) + дата последней поставки лида.
|
||||
|
||||
@@ -79,7 +79,6 @@ class DashboardController extends Controller
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->count();
|
||||
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
|
||||
|
||||
// --- activity: 7 daily-бакетов по received_at (MSK) ---
|
||||
$activityStart = $now->subDays(6)->startOfDay();
|
||||
@@ -141,7 +140,7 @@ class DashboardController extends Controller
|
||||
'range' => $range,
|
||||
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
|
||||
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'active_projects' => ['active' => $activeProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Bot\ProcessJivoMessageJob;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Приём событий Jivo Bot API (спека §§2,5). Образец защиты — SupplierWebhookController:
|
||||
* секрет в URL (≥32 симв., config services.jivo_bot.webhook_secret), hash_equals,
|
||||
* несовпадение → 404 (не палим endpoint). Ack мгновенный (лимит Jivo 3 сек):
|
||||
* вся работа — в ProcessJivoMessageJob. Обрабатываем только CLIENT_MESSAGE
|
||||
* с непустым текстом; служебные события подтверждаем и игнорируем.
|
||||
*/
|
||||
class JivoBotController extends Controller
|
||||
{
|
||||
public function receive(Request $request, string $secret = ''): JsonResponse
|
||||
{
|
||||
$expected = (string) config('services.jivo_bot.webhook_secret');
|
||||
if ($expected === '' || strlen($expected) < 32 || ! hash_equals($expected, $secret)) {
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
}
|
||||
|
||||
$event = (string) $request->input('event', '');
|
||||
$text = trim((string) $request->input('message.text', ''));
|
||||
$chatId = (string) $request->input('chat_id', '');
|
||||
|
||||
if ($event === 'CLIENT_MESSAGE' && $text !== '' && $chatId !== '') {
|
||||
ProcessJivoMessageJob::dispatch($chatId, (string) $request->input('client_id', ''), $text);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Переключает активное подключение к БД на pgsql_admin (роль crm_admin_user)
|
||||
* на время обработки SaaS-admin запроса и восстанавливает прежнее в finally.
|
||||
*
|
||||
* Зачем: после переезда на Managed PG (Путь А) AdminTenantsController и
|
||||
* AdminBillingController ходят под default-ролью crm_app_user, у которой нет
|
||||
* cross-tenant доступа (RLS tenants_self_isolation) → пустые «Тенанты»/«Биллинг».
|
||||
* crm_admin_user имеет политику srv_bypass + GRANT на админ-таблицы.
|
||||
*
|
||||
* Ставится ПОСЛЕ saas-admin (EnsureSaasAdmin), чтобы гейт и проверка
|
||||
* impersonation прошли под исходным подключением. Контроллеры, явно прибитые к
|
||||
* pgsql_supplier, не затрагиваются — меняется только default.
|
||||
*
|
||||
* См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md
|
||||
*/
|
||||
class UseAdminConnection
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$previous = DB::getDefaultConnection();
|
||||
DB::setDefaultConnection('pgsql_admin');
|
||||
|
||||
try {
|
||||
return $next($request);
|
||||
} finally {
|
||||
DB::setDefaultConnection($previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Bot;
|
||||
|
||||
use App\Models\BotDialog;
|
||||
use App\Services\Bot\BotAnswerService;
|
||||
use App\Services\Bot\JivoBotClient;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
/**
|
||||
* Оркестратор ответа бота (спека §2). Очередь `bot` — отдельный worker на проде,
|
||||
* чтобы поток лидов не задерживал ответы чата (скорость — требование №1).
|
||||
* timeout 12с < 15с Jivo-страховки: не успели — Jivo сам позовёт оператора.
|
||||
* $tries=1: ретраить разговор бессмысленно, клиент уже у живого оператора.
|
||||
*/
|
||||
class ProcessJivoMessageJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 12;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $chatId,
|
||||
public readonly string $clientId,
|
||||
public readonly string $text,
|
||||
) {
|
||||
$this->onQueue('bot');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
|
||||
BotDialog::create([
|
||||
'jivo_chat_id' => $this->chatId,
|
||||
'direction' => 'in',
|
||||
'message' => $this->text,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$answer = app(BotAnswerService::class)->answer($this->text);
|
||||
$jivo = app(JivoBotClient::class);
|
||||
|
||||
$jivo->sendMessage($this->chatId, $this->clientId, $answer->text);
|
||||
if ($answer->escalate) {
|
||||
$jivo->inviteAgent($this->chatId, $this->clientId);
|
||||
}
|
||||
|
||||
BotDialog::create([
|
||||
'jivo_chat_id' => $this->chatId,
|
||||
'direction' => 'out',
|
||||
'message' => $answer->text,
|
||||
'matched_chunks' => $answer->matchedChunkIds,
|
||||
'latency_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000),
|
||||
'escalated' => $answer->escalate,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Строка журнала диалога бота (direction: in/out). created_at only — updated_at нет. */
|
||||
class BotDialog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = ['matched_chunks' => 'array', 'escalated' => 'bool', 'created_at' => 'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Чанк базы знаний бота. search_tsv — generated column, в PHP не трогаем. */
|
||||
class KnowledgeChunk extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -58,7 +58,9 @@ class Tenant extends Model
|
||||
'desired_daily_numbers' => 'integer',
|
||||
'delivered_in_month' => 'integer',
|
||||
'api_key_limit' => 'integer',
|
||||
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
|
||||
// JSONB-резерв тарифных ограничений. Ключ max_projects убран —
|
||||
// лимита по числу проектов нет (ограничение только по балансу/лидам).
|
||||
// max_users / api_rps в коде не используются (зарезервированы).
|
||||
'limits' => 'array',
|
||||
'last_activity_at' => 'datetime',
|
||||
'last_webhook_at' => 'datetime',
|
||||
|
||||
@@ -42,7 +42,7 @@ final class YooKassaDriver implements PaymentGatewayDriver
|
||||
->post(self::BASE.'/payments', $payload);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status());
|
||||
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status().' body='.$resp->body());
|
||||
}
|
||||
|
||||
$id = (string) $resp->json('id');
|
||||
@@ -63,7 +63,7 @@ final class YooKassaDriver implements PaymentGatewayDriver
|
||||
->get(self::BASE.'/payments/'.$gatewayPaymentId);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status());
|
||||
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status().' body='.$resp->body());
|
||||
}
|
||||
|
||||
return new WebhookVerifyResult(
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PaymentGateway;
|
||||
use App\Models\SaasTransaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\Gateway\CreatePaymentResult;
|
||||
use App\Services\Billing\Gateway\PaymentGatewayDriver;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -41,7 +42,25 @@ final class OnlineTopupService
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, null);
|
||||
// Чек 54-ФЗ обязателен на стороне магазина ЮKassa (фискализация включена) —
|
||||
// без секции receipt платёж отклоняется 400 "Receipt is missing". Формируем
|
||||
// всегда. vat_code=1 = «без НДС» (ИП на УСН; проверено живым запросом 26.06.2026).
|
||||
$email = $userId !== null ? User::query()->whereKey($userId)->value('email') : null;
|
||||
$email = is_string($email) && $email !== '' ? $email : (string) config('mail.from.address', 'info@liderra.ru');
|
||||
|
||||
$receipt = [
|
||||
'customer' => ['email' => $email],
|
||||
'items' => [[
|
||||
'description' => 'Пополнение баланса Лидерра',
|
||||
'quantity' => '1.00',
|
||||
'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
|
||||
'vat_code' => 1,
|
||||
'payment_mode' => 'full_prepayment',
|
||||
'payment_subject' => 'service',
|
||||
]],
|
||||
];
|
||||
|
||||
$result = $this->driver->createPayment($gateway, $amountRub, $idempotenceKey, $returnUrl, $receipt);
|
||||
|
||||
$tx->gateway_payment_id = $result->gatewayPaymentId;
|
||||
$tx->save();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
/** Итог обработки вопроса. escalate=true → после текста зовём живого оператора. @param list<int> $matchedChunkIds */
|
||||
class BotAnswer
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $text,
|
||||
public readonly bool $escalate,
|
||||
public readonly array $matchedChunkIds = [],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
/**
|
||||
* Мозг ответа (спека §§4–5,7): стоп-темы → эскалация до LLM; пустой поиск →
|
||||
* честное «не знаю»; иначе YandexGPT строго по найденным фрагментам инструкции.
|
||||
* Tour-ссылка «Показать на портале» — из frontmatter самой релевантной статьи,
|
||||
* только под флагом tours_enabled (включается этапом 3).
|
||||
*/
|
||||
class BotAnswerService
|
||||
{
|
||||
/** Личные данные, деньги конкретного клиента, скидки, юр-темы, просьба человека. */
|
||||
private const STOP_PATTERN = '/(мо[йяеи]\s+(баланс|счет|счёт|деньг|проект|заявк|сделк)|у меня (на )?(балансе|счете|счёте)|скидк|оператор|человек|менеджер|жалоб|претензи|юрист|договор|возврат денег)/iu';
|
||||
|
||||
private const ESCALATE_TEXT = 'Этот вопрос лучше разберёт живой специалист — передаю ему диалог. Он ответит здесь же.';
|
||||
|
||||
private const UNKNOWN_TEXT = 'Честно — в моей инструкции нет ответа на этот вопрос. Передаю живому специалисту, он ответит здесь же.';
|
||||
|
||||
public function __construct(
|
||||
private readonly KnowledgeSearch $search,
|
||||
private readonly YandexGptClient $gpt,
|
||||
) {}
|
||||
|
||||
public function answer(string $question): BotAnswer
|
||||
{
|
||||
if (preg_match(self::STOP_PATTERN, $question) === 1) {
|
||||
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
|
||||
}
|
||||
|
||||
$chunks = $this->search->search($question, 3);
|
||||
if ($chunks === []) {
|
||||
return new BotAnswer(self::UNKNOWN_TEXT, escalate: true);
|
||||
}
|
||||
|
||||
$context = implode("\n\n---\n\n", array_map(
|
||||
fn ($c) => "### {$c->title}\n{$c->content}",
|
||||
$chunks
|
||||
));
|
||||
|
||||
$system = <<<PROMPT
|
||||
Ты — консультант техподдержки портала Лидерра (лиды для бизнеса). Отвечай кратко
|
||||
(2–5 предложений), простым русским языком, дружелюбно и на «вы».
|
||||
СТРОГИЕ ПРАВИЛА: отвечай ТОЛЬКО по приведённым ниже фрагментам инструкции;
|
||||
если ответа в них нет — скажи честно «в инструкции этого нет». Ничего не выдумывай.
|
||||
Не обещай скидок, цен и сроков, которых нет в фрагментах. Не отвечай на вопросы
|
||||
о данных конкретного клиента (баланс, его проекты) — предложи позвать специалиста.
|
||||
|
||||
Фрагменты инструкции:
|
||||
|
||||
{$context}
|
||||
PROMPT;
|
||||
|
||||
$text = $this->gpt->complete($system, $question);
|
||||
if ($text === null) {
|
||||
return new BotAnswer(self::ESCALATE_TEXT, escalate: true);
|
||||
}
|
||||
|
||||
$tour = $chunks[0]->tour;
|
||||
if ($tour !== null && (bool) config('services.jivo_bot.tours_enabled')) {
|
||||
$text .= "\n\n👉 Показать на портале: ".rtrim((string) config('app.url'), '/').'/?tour='.$tour;
|
||||
}
|
||||
|
||||
return new BotAnswer($text, escalate: false, matchedChunkIds: array_map(fn ($c) => (int) $c->id, $chunks));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Исходящие события в Jivo Bot API. outbound_url выдаёт Jivo письмом при
|
||||
* подключении бота (протокол, О-2); пустой URL = dev/CI, событие только в лог.
|
||||
* Формат событий — Jivo Bot API: BOT_MESSAGE (текст клиенту), INVITE_AGENT
|
||||
* (позвать живого оператора).
|
||||
*/
|
||||
class JivoBotClient
|
||||
{
|
||||
public function sendMessage(string $chatId, string $clientId, string $text): void
|
||||
{
|
||||
$this->post([
|
||||
'event' => 'BOT_MESSAGE',
|
||||
'id' => (string) Str::uuid(),
|
||||
'chat_id' => $chatId,
|
||||
'client_id' => $clientId,
|
||||
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => now()->getTimestamp()],
|
||||
]);
|
||||
}
|
||||
|
||||
public function inviteAgent(string $chatId, string $clientId): void
|
||||
{
|
||||
$this->post([
|
||||
'event' => 'INVITE_AGENT',
|
||||
'id' => (string) Str::uuid(),
|
||||
'chat_id' => $chatId,
|
||||
'client_id' => $clientId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $payload */
|
||||
private function post(array $payload): void
|
||||
{
|
||||
$url = (string) config('services.jivo_bot.outbound_url');
|
||||
if ($url === '') {
|
||||
Log::info('JivoBot outbound skipped (no outbound_url)', ['event' => $payload['event']]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(5)->post($url, $payload)->throw();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('JivoBot outbound failure', ['event' => $payload['event'], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Поиск по базе знаний бота: PostgreSQL FTS (russian) по generated-колонке
|
||||
* search_tsv (title+topics+content), ранжирование ts_rank. websearch_to_tsquery
|
||||
* терпим к пользовательскому вводу (спецсимволы не ломают запрос).
|
||||
* Интерфейс намеренно узкий — замена на pgvector позже не тронет вызывающих.
|
||||
*
|
||||
* @return list<KnowledgeChunk>
|
||||
*/
|
||||
class KnowledgeSearch
|
||||
{
|
||||
public function search(string $question, int $limit = 3): array
|
||||
{
|
||||
$question = trim($question);
|
||||
if ($question === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Collection<int, KnowledgeChunk> $hits */
|
||||
$hits = KnowledgeChunk::query()
|
||||
->selectRaw(
|
||||
"knowledge_chunks.*, ts_rank(search_tsv, websearch_to_tsquery('russian', ?)) AS rank",
|
||||
[$question]
|
||||
)
|
||||
->whereRaw("search_tsv @@ websearch_to_tsquery('russian', ?)", [$question])
|
||||
->orderByDesc('rank')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return $hits->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Bot;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (протокол, решение 8).
|
||||
* Возвращает null при любой беде (нет ключа, таймаут, 5xx) — решение об эскалации
|
||||
* принимает вызывающий. Таймаут 8 сек — бюджет скорости из спеки §6.
|
||||
*/
|
||||
class YandexGptClient
|
||||
{
|
||||
public function complete(string $systemPrompt, string $userText): ?string
|
||||
{
|
||||
$cfg = (array) config('services.yandexgpt');
|
||||
if (($cfg['api_key'] ?? '') === '' || ($cfg['folder_id'] ?? '') === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout((int) ($cfg['timeout_seconds'] ?? 8))
|
||||
->withHeaders(['Authorization' => 'Api-Key '.$cfg['api_key']])
|
||||
->post((string) $cfg['endpoint'], [
|
||||
'modelUri' => sprintf('gpt://%s/%s', $cfg['folder_id'], $cfg['model']),
|
||||
'completionOptions' => ['stream' => false, 'temperature' => 0.2, 'maxTokens' => 500],
|
||||
'messages' => [
|
||||
['role' => 'system', 'text' => $systemPrompt],
|
||||
['role' => 'user', 'text' => $userText],
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
Log::warning('YandexGPT non-OK', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $response->json('result.alternatives.0.message.text');
|
||||
|
||||
return is_string($text) && $text !== '' ? $text : null;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('YandexGPT failure', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Dashboard;
|
||||
|
||||
/**
|
||||
* Сверка заказа у поставщика для дашборда: спрос клиентов → надо по формуле
|
||||
* → заказали по факту → совпадает ли. Чистая логика (без БД), тестируема.
|
||||
*
|
||||
* Формула = SupplierQuotaAllocator::computeOrder = max(max(лимитов), ceil(сумма/3)).
|
||||
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
|
||||
*/
|
||||
class SupplyReconciliation
|
||||
{
|
||||
/**
|
||||
* @param list<array{signal_type:string,identifier:string,demand:int,max_limit:int}> $demand
|
||||
* @param array<string,int> $orderedByKey ключ "signal_type|identifier" => SUM(current_limit)
|
||||
* @return array{groups:list<array{signal_type:string,identifier:string,demand:int,formula:int,ordered:int,in_sync:bool}>,totals:array{demand:int,formula:int,ordered:int,mismatches:int}}
|
||||
*/
|
||||
public static function build(array $demand, array $orderedByKey): array
|
||||
{
|
||||
$groups = [];
|
||||
$sumDemand = 0;
|
||||
$sumFormula = 0;
|
||||
$sumOrdered = 0;
|
||||
$mismatches = 0;
|
||||
|
||||
foreach ($demand as $d) {
|
||||
$formula = max((int) $d['max_limit'], (int) ceil($d['demand'] / 3));
|
||||
$key = $d['signal_type'].'|'.$d['identifier'];
|
||||
$ordered = (int) ($orderedByKey[$key] ?? 0);
|
||||
$inSync = $formula === $ordered;
|
||||
|
||||
$groups[] = [
|
||||
'signal_type' => (string) $d['signal_type'],
|
||||
'identifier' => (string) $d['identifier'],
|
||||
'demand' => (int) $d['demand'],
|
||||
'formula' => $formula,
|
||||
'ordered' => $ordered,
|
||||
'in_sync' => $inSync,
|
||||
];
|
||||
|
||||
$sumDemand += (int) $d['demand'];
|
||||
$sumFormula += $formula;
|
||||
$sumOrdered += $ordered;
|
||||
if (! $inSync) {
|
||||
$mismatches++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'groups' => $groups,
|
||||
'totals' => [
|
||||
'demand' => $sumDemand,
|
||||
'formula' => $sumFormula,
|
||||
'ordered' => $sumOrdered,
|
||||
'mismatches' => $mismatches,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -619,14 +619,9 @@ class ProjectService
|
||||
|
||||
public function create(Tenant $tenant, array $data): Project
|
||||
{
|
||||
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
|
||||
$current = Project::where('tenant_id', $tenant->id)->count();
|
||||
if ($current >= $limit) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
|
||||
], 403));
|
||||
}
|
||||
|
||||
// Лимита по числу проектов нет — ограничение только по балансу/заказанным
|
||||
// лидам (балансовый префлайт в ProjectController::store). Прежний гейт
|
||||
// tenants.limits['max_projects'] убран как противоречащий правилу продукта.
|
||||
$data['tenant_id'] = $tenant->id;
|
||||
$data['is_active'] = true;
|
||||
$data['regions'] = $data['regions'] ?? [];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Help;
|
||||
|
||||
/** Разобранная статья инструкции. @param list<string> $chunks */
|
||||
class HelpArticle
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $sourcePath,
|
||||
public readonly string $title,
|
||||
public readonly ?string $tour,
|
||||
public readonly string $topics,
|
||||
public readonly array $chunks,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Help;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Разбор статьи клиентской инструкции (resources/help/*.md):
|
||||
* frontmatter (title/tour/topics, простые key: value между «---») + тело,
|
||||
* порезанное на чанки ~1200 симв. по границам абзацев (для FTS-поиска).
|
||||
* Без YAML-зависимости — формат статей намеренно плоский.
|
||||
*/
|
||||
class HelpArticleParser
|
||||
{
|
||||
private const CHUNK_TARGET_CHARS = 1200;
|
||||
|
||||
public function parse(string $sourcePath, string $markdown): HelpArticle
|
||||
{
|
||||
if (! preg_match('/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/su', trim($markdown), $m)) {
|
||||
throw new InvalidArgumentException("Статья {$sourcePath}: нет frontmatter (--- title/topics ---).");
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
foreach (preg_split('/\r?\n/', $m[1]) as $line) {
|
||||
if (preg_match('/^(\w+):\s*(.*)$/u', trim($line), $kv)) {
|
||||
$meta[$kv[1]] = trim($kv[2]);
|
||||
}
|
||||
}
|
||||
if (($meta['title'] ?? '') === '') {
|
||||
throw new InvalidArgumentException("Статья {$sourcePath}: пустой title во frontmatter.");
|
||||
}
|
||||
|
||||
$paragraphs = array_values(array_filter(
|
||||
array_map('trim', preg_split('/\r?\n\r?\n+/', trim($m[2]))),
|
||||
fn (string $p) => $p !== ''
|
||||
));
|
||||
|
||||
$chunks = [];
|
||||
$current = '';
|
||||
foreach ($paragraphs as $p) {
|
||||
if ($current !== '' && mb_strlen($current) + mb_strlen($p) > self::CHUNK_TARGET_CHARS) {
|
||||
$chunks[] = $current;
|
||||
$current = $p;
|
||||
} else {
|
||||
$current = $current === '' ? $p : $current."\n\n".$p;
|
||||
}
|
||||
}
|
||||
if ($current !== '') {
|
||||
$chunks[] = $current;
|
||||
}
|
||||
|
||||
return new HelpArticle(
|
||||
sourcePath: $sourcePath,
|
||||
title: $meta['title'],
|
||||
tour: ($meta['tour'] ?? '') !== '' ? $meta['tour'] : null,
|
||||
topics: $meta['topics'] ?? '',
|
||||
chunks: $chunks,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use App\Http\Middleware\ApiKeyAuth;
|
||||
use App\Http\Middleware\EnsureSaasAdmin;
|
||||
use App\Http\Middleware\ImpersonationContext;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use App\Http\Middleware\UseAdminConnection;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -27,6 +28,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->alias([
|
||||
'tenant' => SetTenantContext::class,
|
||||
'saas-admin' => EnsureSaasAdmin::class,
|
||||
'admin-db' => UseAdminConnection::class,
|
||||
'apikey' => ApiKeyAuth::class,
|
||||
]);
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ $pgsqlConnection = [
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
// Managed PG (Путь А, 26.06.2026): CA-файл для sslmode=verify-full. Если DB_SSLROOTCERT
|
||||
// не задан (dev/локально) — env() вернёт null, Laravel-коннектор ключ пропустит (isset=false),
|
||||
// поведение не меняется. На проде: DB_SSLMODE=verify-full + DB_SSLROOTCERT=<путь к CA>.
|
||||
'sslrootcert' => env('DB_SSLROOTCERT'),
|
||||
// PG session timezone = UTC. Без этого TIMESTAMPTZ возвращается с локальным offset
|
||||
// (+03), а Carbon::parse теряет offset → password reset token expiry-check
|
||||
// и аналогичные TZ-чувствительные сравнения ломаются.
|
||||
@@ -140,6 +144,22 @@ return [
|
||||
]
|
||||
),
|
||||
|
||||
// Путь А (27.06.2026): dedicated PG connection для SaaS-admin зоны под
|
||||
// ролью crm_admin_user (политика srv_bypass = видит все тенанты + GRANT на
|
||||
// админ-таблицы). Используется через middleware UseAdminConnection (alias
|
||||
// admin-db) на группе saas-admin: AdminTenantsController / AdminBillingController
|
||||
// ходят под default → получают cross-tenant доступ. На dev fallback на
|
||||
// DB_USERNAME/DB_PASSWORD (postgres superuser). На prod ОБЯЗАТЕЛЬНО задать
|
||||
// DB_ADMIN_USERNAME=crm_admin_user + DB_ADMIN_PASSWORD.
|
||||
// См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md
|
||||
'pgsql_admin' => array_merge(
|
||||
$pgsqlConnection,
|
||||
[
|
||||
'username' => env('DB_ADMIN_USERNAME', env('DB_USERNAME', 'root')),
|
||||
'password' => env('DB_ADMIN_PASSWORD', env('DB_PASSWORD', '')),
|
||||
]
|
||||
),
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
|
||||
@@ -75,6 +75,25 @@ return [
|
||||
'widget_id' => env('JIVO_WIDGET_ID'),
|
||||
],
|
||||
|
||||
// ИИ-бот техподдержки в чате Jivo (спека 2026-07-02-jivo-ai-support-bot-design).
|
||||
// webhook_secret — входящий секрет в URL (≥32 симв., по образцу supplier webhook).
|
||||
// outbound_url/token — выдаёт Jivo письмом при подключении Bot API; пусто → отправка
|
||||
// событий отключена (dev/CI), бот пишет только в журнал.
|
||||
'jivo_bot' => [
|
||||
'webhook_secret' => env('JIVO_BOT_WEBHOOK_SECRET', ''),
|
||||
'outbound_url' => env('JIVO_BOT_OUTBOUND_URL', ''),
|
||||
'token' => env('JIVO_BOT_TOKEN', ''),
|
||||
'tours_enabled' => env('JIVO_BOT_TOURS_ENABLED', false),
|
||||
],
|
||||
// YandexGPT Lite (Yandex Cloud Foundation Models) — мозг бота (решение 8 протокола).
|
||||
'yandexgpt' => [
|
||||
'api_key' => env('YANDEX_GPT_API_KEY', ''),
|
||||
'folder_id' => env('YANDEX_GPT_FOLDER_ID', ''),
|
||||
'model' => env('YANDEX_GPT_MODEL', 'yandexgpt-lite/latest'),
|
||||
'endpoint' => env('YANDEX_GPT_ENDPOINT', 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion'),
|
||||
'timeout_seconds' => 8,
|
||||
],
|
||||
|
||||
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
|
||||
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
|
||||
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
|
||||
|
||||
@@ -9,8 +9,10 @@ use Illuminate\Support\Facades\Schema;
|
||||
/**
|
||||
* Plan 5 Task 3: добавить limits JSONB в tenants.
|
||||
*
|
||||
* Используется ProjectService::create() для проверки лимита max_projects.
|
||||
* Default '{}' → (int)($tenant->limits['max_projects'] ?? 10) = 10 из сервиса.
|
||||
* NB (2026-06-27): ключ max_projects и гейт по числу проектов убраны —
|
||||
* лимита по количеству проектов нет (ограничение только по балансу/лидам).
|
||||
* Колонка limits оставлена как резерв тарифных ограничений (max_users / api_rps
|
||||
* пока не используются). Default '{}'.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
@@ -20,8 +22,8 @@ return new class extends Migration
|
||||
return;
|
||||
}
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
// limits JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
|
||||
// Аналог limits в tariff_plans — per-tenant override лимитов тарифа.
|
||||
// limits JSONB — резерв per-tenant override тарифных ограничений
|
||||
// (max_users / api_rps зарезервированы; max_projects убран 2026-06-27).
|
||||
$table->jsonb('limits')->default('{}')->after('api_key_limit');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Путь А (Managed PG): пересчёт hash-цепочки аудита без session_replication_role
|
||||
* (superuser-only, недоступен в управляемой базе Яндекса).
|
||||
*
|
||||
* audit_block_mutation() теперь пропускает мутацию при метке app.audit_rebuild='on'
|
||||
* И (superuser — для dev/test postgres) ИЛИ (членство в crm_migrator — покрывает
|
||||
* crm_supplier_worker, под которым AuditRebuildChain идёт на проде через pgsql_supplier).
|
||||
* Проверка членства защищена EXISTS-гардом, чтобы не падать на dev, где роли crm_* нет.
|
||||
*
|
||||
* Поведение append-only сохранено: без метки любой UPDATE/DELETE аудита запрещён.
|
||||
* См. docs/superpowers/findings/2026-06-26-db-migration/etap1-sandbox-results.md (шов C).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.audit_block_mutation()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $function$
|
||||
BEGIN
|
||||
IF current_setting('app.audit_rebuild', true) = 'on' THEN
|
||||
-- dev/test: postgres superuser
|
||||
IF (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
-- managed: член crm_migrator (в т.ч. crm_supplier_worker)
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
IF pg_has_role(current_user, 'crm_migrator', 'MEMBER') THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
|
||||
USING ERRCODE = 'check_violation';
|
||||
END;
|
||||
$function$;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.audit_block_mutation()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $function$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME
|
||||
USING ERRCODE = 'check_violation';
|
||||
END;
|
||||
$function$;
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* База знаний ИИ-бота (спека 2026-07-02-jivo-ai-support-bot-design §3).
|
||||
* Глобальная таблица (НЕ tenant-scoped): только публичные статьи инструкции,
|
||||
* данных клиентов здесь нет по определению — RLS не требуется.
|
||||
* search_tsv — generated column (russian) + GIN: поиск за миллисекунды.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_path VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
tour VARCHAR(100),
|
||||
topics TEXT NOT NULL DEFAULT '',
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
search_tsv tsvector GENERATED ALWAYS AS (
|
||||
to_tsvector('russian', coalesce(title, '') || ' ' || coalesce(topics, '') || ' ' || coalesce(content, ''))
|
||||
) STORED,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_knowledge_chunks_source_chunk UNIQUE (source_path, chunk_index)
|
||||
)
|
||||
SQL);
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_search ON knowledge_chunks USING GIN (search_tsv)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS knowledge_chunks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Журнал диалогов ИИ-бота (спека §5). Глобальная (НЕ tenant-scoped) в v1:
|
||||
* диалоги Jivo анонимны до этапа личных ответов; ПДн клиентов не пишем.
|
||||
* direction: in = сообщение клиента, out = ответ бота.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS bot_dialogs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
jivo_chat_id VARCHAR(64) NOT NULL,
|
||||
direction VARCHAR(3) NOT NULL CHECK (direction IN ('in', 'out')),
|
||||
message TEXT NOT NULL,
|
||||
matched_chunks JSONB,
|
||||
latency_ms INTEGER,
|
||||
escalated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
SQL);
|
||||
DB::statement('CREATE INDEX IF NOT EXISTS idx_bot_dialogs_chat ON bot_dialogs (jivo_chat_id, created_at)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS bot_dialogs');
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
deptrac:
|
||||
skip_violations:
|
||||
App\Http\Resources\ProjectResource:
|
||||
- App\Services\Project\ProjectRuleMessages
|
||||
- App\Services\Project\SupplierSnapshotGuard
|
||||
|
||||
+7
-5
@@ -1,9 +1,11 @@
|
||||
imports:
|
||||
# Принятые текущие нарушения (см. комментарий ruleset ниже). Сейчас один:
|
||||
# ProjectResource → SupplierSnapshotGuard — read-only расчёт состояния замка
|
||||
# источника для отображения в UI; перенос в контроллер усложнил бы коллекции
|
||||
# без выигрыша. Гейт ловит только НОВЫЙ дрейф. Регенерация: deptrac analyse
|
||||
# --formatter=baseline --output=deptrac.baseline.yaml.
|
||||
# Принятые текущие нарушения (см. комментарий ruleset ниже). Сейчас два,
|
||||
# оба ProjectResource → Service, оба read-only UI-вычисления (ADR-005):
|
||||
# - SupplierSnapshotGuard — расчёт состояния замка источника для UI;
|
||||
# - ProjectRuleMessages — единый текст правил сбора (Эпик 6, баннеры);
|
||||
# перенос в контроллер усложнил бы коллекции без выигрыша. Гейт ловит только
|
||||
# НОВЫЙ дрейф. Регенерация: deptrac analyse --formatter=baseline
|
||||
# --output=deptrac.baseline.yaml.
|
||||
- deptrac.baseline.yaml
|
||||
|
||||
deptrac:
|
||||
|
||||
+245
-77
@@ -6,6 +6,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Console/Commands/PhoneRangesImportCommand.php
|
||||
|
||||
-
|
||||
message: '#^Strict comparison using \=\=\= between int and null will always evaluate to false\.$#'
|
||||
identifier: identical.alwaysFalse
|
||||
count: 1
|
||||
path: app/Http/Controllers/Api/AdminPaymentGatewayController.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$tariff_name\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -114,6 +120,12 @@ parameters:
|
||||
count: 2
|
||||
path: app/Mail/NewLeadNotification.php
|
||||
|
||||
-
|
||||
message: '#^Strict comparison using \=\=\= between string and null will always evaluate to false\.$#'
|
||||
identifier: identical.alwaysFalse
|
||||
count: 1
|
||||
path: app/Models/PaymentGateway.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with array\<mixed\> will always evaluate to true\.$#'
|
||||
identifier: function.alreadyNarrowedType
|
||||
@@ -210,6 +222,12 @@ parameters:
|
||||
count: 1
|
||||
path: routes/console.php
|
||||
|
||||
-
|
||||
message: '#^Trait Tests\\Concerns\\SharesAdminPdo is used zero times and is not analysed\.$#'
|
||||
identifier: trait.unused
|
||||
count: 1
|
||||
path: tests/Concerns/SharesAdminPdo.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -270,6 +288,36 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Account/UserSessionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminDashboardFinanceTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminDashboardHealthTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminDashboardLeadsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/AdminDashboardSummaryTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminDashboardSupplyTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -468,6 +516,24 @@ parameters:
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Admin/SupplierSourceEditFlagEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierSourceEditFlagEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/SupplierSourceEditFlagEndpointTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -576,6 +642,12 @@ parameters:
|
||||
count: 15
|
||||
path: tests/Feature/Api/ProjectBulkActionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Api/ProjectResourceBalanceBlockedTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -663,7 +735,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 13
|
||||
count: 15
|
||||
path: tests/Feature/Auth/AuthFlowIntegrationTest.php
|
||||
|
||||
-
|
||||
@@ -675,7 +747,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 21
|
||||
count: 22
|
||||
path: tests/Feature/Auth/AuthLogCoverageTest.php
|
||||
|
||||
-
|
||||
@@ -708,6 +780,18 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Auth/IpLockoutTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Auth/LoginUnconfirmedEmailTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/LoginUnconfirmedEmailTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -738,6 +822,12 @@ parameters:
|
||||
count: 9
|
||||
path: tests/Feature/Auth/NotificationPreferencesTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/PasswordResetUrlTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -888,6 +978,18 @@ parameters:
|
||||
count: 11
|
||||
path: tests/Feature/Auth/TwoFactorTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -918,6 +1020,12 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/AdminPaymentGatewayTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -951,7 +1059,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 20
|
||||
count: 21
|
||||
path: tests/Feature/Billing/BillingOverviewControllerTest.php
|
||||
|
||||
-
|
||||
@@ -996,6 +1104,72 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/OnlineTopupServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$driver of class App\\Services\\Billing\\OnlineTopupService constructor expects App\\Services\\Billing\\Gateway\\PaymentGatewayDriver, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Feature/Billing/OnlineTopupServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$gw\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Billing/PaymentWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 14
|
||||
path: tests/Feature/Billing/PaymentWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Billing/PaymentWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Billing/PaymentWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/PreflightUsesCurrentTariffVersionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$repo\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1005,7 +1179,19 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
count: 6
|
||||
path: tests/Feature/Billing/ProjectBlockedSyncGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/ProjectBulkLimitPreflightTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Billing/ProjectPreflightTest.php
|
||||
|
||||
-
|
||||
@@ -1080,6 +1266,12 @@ parameters:
|
||||
count: 8
|
||||
path: tests/Feature/Billing/TopupControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Billing/TopupFlagForkTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1167,13 +1359,13 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 10
|
||||
count: 11
|
||||
path: tests/Feature/DashboardSummaryTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
count: 2
|
||||
path: tests/Feature/DashboardSummaryTest.php
|
||||
|
||||
-
|
||||
@@ -1299,7 +1491,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 38
|
||||
count: 40
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -1311,7 +1503,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 41
|
||||
count: 45
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -1329,7 +1521,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 29
|
||||
count: 31
|
||||
path: tests/Feature/DealIndexTest.php
|
||||
|
||||
-
|
||||
@@ -1737,7 +1929,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 17
|
||||
count: 16
|
||||
path: tests/Feature/ImpersonationTest.php
|
||||
|
||||
-
|
||||
@@ -1848,18 +2040,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Integration/SupplierLeadFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$phone_operator\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$region_substituted\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1902,6 +2082,12 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/LeadRouter/FrozenFilterTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/LeadRouter/LeadFlowChangedDeletedTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
@@ -2217,7 +2403,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 12
|
||||
count: 13
|
||||
path: tests/Feature/Plan5/Projects/ProjectsStoreTest.php
|
||||
|
||||
-
|
||||
@@ -2244,6 +2430,30 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Project/ProjectPhoneNormalizationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2268,6 +2478,12 @@ parameters:
|
||||
count: 4
|
||||
path: tests/Feature/Projects/ProjectMutationsAuditTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Public/PublicPricingTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -2622,36 +2838,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/NoDoubleChargeOnSourceChangeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/LeadRouter/LeadFlowChangedDeletedTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2664,12 +2850,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectTailGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andReturnNull\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/OnlineDeferWindowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2718,6 +2898,12 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/ImportSupplierProjectsCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andReturnNull\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/OnlineDeferWindowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -3005,21 +3191,3 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Auth/PasswordResetUrlTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Auth/UnauthenticatedApiResponseTest.php
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Что такое проект
|
||||
tour: create-project
|
||||
topics: создать проект, заявка на лиды, источник, сайт конкурента, лимит заявок, новый проект
|
||||
---
|
||||
|
||||
Проект — это ваша заявка на поток клиентов. Вы указываете источник (например, сайт,
|
||||
похожий на ваш бизнес) и сколько заявок в день хотите получать — а система начинает
|
||||
присылать вам заявки с контактами.
|
||||
|
||||
Как создать: раздел «Проекты» → кнопка «Создать проект». Понадобится указать название,
|
||||
источник и дневной лимит заявок. После создания проект начинает работать не сразу —
|
||||
обычно в течение суток.
|
||||
|
||||
Заявки из проекта появляются в разделе «Сделки» — с телефоном, источником и статусом.
|
||||
Проект можно поставить на паузу или изменить лимит в любой момент.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Как пополнить баланс
|
||||
tour: top-up-balance
|
||||
topics: пополнить, закинуть деньги, оплата, счёт, платёж, банковская карта, безнал, пополнение баланса
|
||||
---
|
||||
|
||||
Пополнить баланс: раздел «Биллинг» → кнопка «Пополнить». Доступна оплата по счёту
|
||||
для юридических лиц и ИП: система выставит PDF-счёт, после оплаты деньги зачислятся,
|
||||
а акт придёт на почту.
|
||||
|
||||
Зачисление по счёту происходит после подтверждения оплаты. Баланс и историю всех
|
||||
операций видно в разделе «Биллинг».
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Тарифы и списания
|
||||
tour: tariffs
|
||||
topics: сколько стоит, цена заявки, цена лида, тариф, списание, деньги, стоимость, оплата за лид
|
||||
---
|
||||
|
||||
Вы платите только за полученные заявки — абонентской платы нет. Деньги списываются
|
||||
с баланса за каждую доставленную заявку по вашей тарифной ступени.
|
||||
|
||||
Тарифная ступень зависит от объёма: чем больше заявок в месяц, тем дешевле каждая.
|
||||
Актуальные цены — раздел «Биллинг» → «Тарифы».
|
||||
|
||||
Если на балансе не хватает денег на очередную заявку, проекты автоматически встают
|
||||
на паузу — ничего не сгорает, после пополнения работа продолжается.
|
||||
@@ -0,0 +1,127 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* SaaS-admin «Командный центр» — типизированный клиент read-only агрегатов.
|
||||
*
|
||||
* Все 3 эндпоинта — GET под группой ['saas-admin','admin-db'] (cross-tenant
|
||||
* через pgsql_admin). CSRF не нужен (только чтение).
|
||||
* Backend: AdminDashboardController. Spec:
|
||||
* docs/superpowers/specs/2026-06-27-admin-command-center-design.md
|
||||
*/
|
||||
|
||||
export type Light = 'green' | 'amber' | 'red';
|
||||
|
||||
export interface DashboardSummary {
|
||||
period: string;
|
||||
finance: {
|
||||
topups_rub: string;
|
||||
charges_rub: string;
|
||||
active_clients: number;
|
||||
new_clients: number;
|
||||
negative_balance_count: number;
|
||||
light: Light;
|
||||
};
|
||||
health: {
|
||||
light: Light;
|
||||
open_incidents: number;
|
||||
job_errors_24h: number;
|
||||
failed_jobs_24h: number;
|
||||
last_sync_status: string;
|
||||
last_sync_at: string | null;
|
||||
};
|
||||
leads: {
|
||||
light: Light;
|
||||
delivered_today: number;
|
||||
received_today: number;
|
||||
stuck: number;
|
||||
unrouted: number;
|
||||
};
|
||||
supply: {
|
||||
light: Light;
|
||||
demand: number;
|
||||
formula: number;
|
||||
ordered: number;
|
||||
mismatches: number;
|
||||
total_orders: number;
|
||||
total_limit: number;
|
||||
snapshot_date: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LeadsDetail {
|
||||
light: Light;
|
||||
kpi: {
|
||||
delivered_today: number;
|
||||
received_today: number;
|
||||
stuck: number;
|
||||
unrouted: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SupplyDetail {
|
||||
snapshot_date: string | null;
|
||||
light: Light;
|
||||
totals: { demand: number; formula: number; ordered: number; mismatches: number };
|
||||
total_orders: number;
|
||||
total_limit: number;
|
||||
groups: Array<{
|
||||
signal_type: string;
|
||||
identifier: string;
|
||||
demand: number;
|
||||
formula: number;
|
||||
ordered: number;
|
||||
in_sync: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FinanceDetail {
|
||||
period: string;
|
||||
kpi: {
|
||||
topups_rub: string;
|
||||
charges_rub: string;
|
||||
net_inflow_rub: string;
|
||||
negative_balance_count: number;
|
||||
};
|
||||
attention: Array<{
|
||||
id: number;
|
||||
subdomain: string;
|
||||
organization_name: string;
|
||||
balance_rub: string;
|
||||
state: string;
|
||||
}>;
|
||||
top_by_turnover: Array<{
|
||||
id: number;
|
||||
organization_name: string;
|
||||
topped_rub: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface HealthDetail {
|
||||
overall_light: Light;
|
||||
subsystems: Array<{ key: string; light: Light; detail: string }>;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(period: string): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/admin/dashboard', { params: { period } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardFinance(period: string): Promise<FinanceDetail> {
|
||||
const { data } = await apiClient.get<FinanceDetail>('/api/admin/dashboard/finance', { params: { period } });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardHealth(): Promise<HealthDetail> {
|
||||
const { data } = await apiClient.get<HealthDetail>('/api/admin/dashboard/health');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardLeads(): Promise<LeadsDetail> {
|
||||
const { data } = await apiClient.get<LeadsDetail>('/api/admin/dashboard/leads');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardSupply(): Promise<SupplyDetail> {
|
||||
const { data } = await apiClient.get<SupplyDetail>('/api/admin/dashboard/supply');
|
||||
return data;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface DashboardSummary {
|
||||
range: string;
|
||||
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
|
||||
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
|
||||
active_projects: { active: number; limit: number };
|
||||
active_projects: { active: number };
|
||||
balance: { amount_rub: string; runway_days: number | null; runway_leads: number };
|
||||
activity: { points: number[]; labels: string[]; max: number };
|
||||
funnel: Record<string, number>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../css/tokens.css';
|
||||
import '../css/typography.css';
|
||||
import '../css/motion.css';
|
||||
import { router } from './router';
|
||||
import { installMenuRepositionFix } from './utils/menuRepositionFix';
|
||||
|
||||
// Точка входа Vue 3 + Vuetify 3 + Vue Router 4 + Pinia (фаза 2, CLAUDE.md §3.3).
|
||||
// Mount в <div id="app"></div> внутри Blade-шаблона `welcome.blade.php`.
|
||||
@@ -14,3 +15,7 @@ app.use(createPinia());
|
||||
app.use(vuetify);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
// Глобальный обход бага позиционирования меню Vuetify (один наблюдатель на всё
|
||||
// приложение) — подробности в utils/menuRepositionFix.ts.
|
||||
installMenuRepositionFix();
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useNotificationsStore } from '../../stores/notifications';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
defineProps<{
|
||||
pageTitle: string;
|
||||
@@ -116,7 +115,6 @@ async function handleLogout(): Promise<void> {
|
||||
offset="8"
|
||||
:close-on-content-click="false"
|
||||
location="bottom end"
|
||||
@update:model-value="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #activator="{ props: bellProps }">
|
||||
<v-btn
|
||||
@@ -179,7 +177,7 @@ async function handleLogout(): Promise<void> {
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-menu offset="8" @update:model-value="repositionMenuAfterOpen">
|
||||
<v-menu offset="8">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
|
||||
<v-avatar size="28" color="primary" class="mr-2">
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* GuidedTour — обобщённый раннер экскурсий (спека ИИ-бота §4, этап 3).
|
||||
* Отличия от WelcomeTour: шаги пропсом; цель шага может появиться ПОСЛЕ
|
||||
* действия клиента (открыл диалог) — меряем с ретраем каждые 300мс до 15с.
|
||||
* Разметка/стили — по образцу WelcomeTour (единый вид подсказок).
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import type { TourStep } from '../../tours/catalog';
|
||||
|
||||
const props = defineProps<{ steps: TourStep[]; active: boolean }>();
|
||||
const emit = defineEmits<{ finish: [] }>();
|
||||
|
||||
const RETRY_MS = 300;
|
||||
const RETRY_MAX = 50; // 15 сек
|
||||
|
||||
const stepIndex = ref(0);
|
||||
const targetRect = ref<{ top: number; left: number; width: number; height: number } | null>(null);
|
||||
let retryTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const currentStep = computed(() => props.steps[stepIndex.value]);
|
||||
const isLast = computed(() => stepIndex.value === props.steps.length - 1);
|
||||
|
||||
function stopRetry(): void {
|
||||
if (retryTimer !== null) {
|
||||
clearInterval(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function measure(): void {
|
||||
stopRetry();
|
||||
targetRect.value = null;
|
||||
const sel = currentStep.value?.target;
|
||||
if (!sel) return;
|
||||
let attempts = 0;
|
||||
const tryMeasure = (): void => {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||
stopRetry();
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts >= RETRY_MAX) stopRetry();
|
||||
};
|
||||
tryMeasure();
|
||||
if (targetRect.value === null) {
|
||||
retryTimer = setInterval(tryMeasure, RETRY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const highlightStyle = computed(() => {
|
||||
const r = targetRect.value;
|
||||
if (!r) return { display: 'none' };
|
||||
const pad = 6;
|
||||
return {
|
||||
top: `${r.top - pad}px`,
|
||||
left: `${r.left - pad}px`,
|
||||
width: `${r.width + pad * 2}px`,
|
||||
height: `${r.height + pad * 2}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
const r = targetRect.value;
|
||||
if (!r) return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' };
|
||||
return { top: `${Math.max(12, r.top)}px`, left: `${r.left + r.width + 16}px` };
|
||||
});
|
||||
|
||||
function next(): void {
|
||||
if (isLast.value) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
stepIndex.value += 1;
|
||||
measure();
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
stopRetry();
|
||||
emit('finish');
|
||||
}
|
||||
|
||||
function onResize(): void {
|
||||
if (props.active) measure();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(on) => {
|
||||
if (on) {
|
||||
stepIndex.value = 0;
|
||||
requestAnimationFrame(() => measure());
|
||||
window.addEventListener('resize', onResize);
|
||||
} else {
|
||||
stopRetry();
|
||||
window.removeEventListener('resize', onResize);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRetry();
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
defineExpose({ stepIndex, targetRect, next, finish });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active && currentStep" class="guided-tour" data-testid="guided-tour">
|
||||
<div class="guided-tour__backdrop" />
|
||||
<div v-if="targetRect" class="guided-tour__highlight" :style="highlightStyle" />
|
||||
<div class="guided-tour__card" :style="tooltipStyle" role="dialog" aria-modal="true">
|
||||
<div class="guided-tour__step">Шаг {{ stepIndex + 1 }} из {{ steps.length }}</div>
|
||||
<h3 class="guided-tour__title">{{ currentStep.title }}</h3>
|
||||
<p class="guided-tour__text">{{ currentStep.text }}</p>
|
||||
<div class="guided-tour__actions">
|
||||
<v-btn variant="text" size="small" data-testid="tour-skip" @click="finish">Закрыть</v-btn>
|
||||
<v-btn color="primary" variant="flat" size="small" data-testid="tour-next" @click="next">
|
||||
{{ isLast ? 'Готово' : 'Далее' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guided-tour {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.guided-tour__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(1, 32, 25, 0.55);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.guided-tour__highlight {
|
||||
position: absolute;
|
||||
border: 2px solid var(--liderra-teal, #0f6e56);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 9999px rgba(1, 32, 25, 0.55);
|
||||
transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.guided-tour__card {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 24px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.guided-tour__step {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7470;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.guided-tour__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 4px 0 6px;
|
||||
color: #081319;
|
||||
}
|
||||
.guided-tour__text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #3a423f;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.guided-tour__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,6 @@ import axios from 'axios';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import { useProjectsStore } from '../../stores/projectsStore';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
@@ -327,7 +326,6 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-add-select"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
@@ -48,7 +47,6 @@
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-remove-select"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
@@ -78,7 +76,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; count: number }>();
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Запуск экскурсии по ссылке из чата бота: /?tour=<имя> (спека ИИ-бота §4).
|
||||
* Невошедшего роутер сам отправит на /login с redirect=fullPath — query
|
||||
* переживает вход (router/index.ts beforeEach), поэтому отдельной логики
|
||||
* логина здесь нет. Мусорное имя — молча чистим query (не пугаем клиента).
|
||||
*/
|
||||
import { ref, type Ref } from 'vue';
|
||||
import type { Router } from 'vue-router';
|
||||
import { findTour, type TourScenario } from '../tours/catalog';
|
||||
|
||||
interface RouteLike {
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function useTourLauncher(route: Ref<RouteLike>, router: Router) {
|
||||
const activeTour = ref<TourScenario | null>(null);
|
||||
|
||||
async function checkQuery(): Promise<void> {
|
||||
const name = typeof route.value.query.tour === 'string' ? route.value.query.tour : '';
|
||||
if (name === '') return;
|
||||
const tour = findTour(name);
|
||||
if (tour === null) {
|
||||
await router.replace({ query: { ...route.value.query, tour: undefined } });
|
||||
return;
|
||||
}
|
||||
activeTour.value = tour;
|
||||
await router.push({ path: tour.steps[0].route, query: {} });
|
||||
}
|
||||
|
||||
function finishTour(): void {
|
||||
activeTour.value = null;
|
||||
}
|
||||
|
||||
return { activeTour, checkQuery, finishTour };
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useNotificationsStore } from '../stores/notifications';
|
||||
import { useTenantStore } from '../stores/tenantStore';
|
||||
@@ -24,12 +24,17 @@ import JivoWidget from '../components/support/JivoWidget.vue';
|
||||
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
|
||||
import ImpersonationSessionBanner from '../components/admin/ImpersonationSessionBanner.vue';
|
||||
import WelcomeTour from '../components/layout/WelcomeTour.vue';
|
||||
import GuidedTour from '../components/layout/GuidedTour.vue';
|
||||
import { useTourLauncher } from '../composables/useTourLauncher';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const notifications = useNotificationsStore();
|
||||
const tenant = useTenantStore();
|
||||
const route = useRoute();
|
||||
|
||||
const router = useRouter();
|
||||
const tourLauncher = useTourLauncher(computed(() => route), router);
|
||||
|
||||
const drawerOpen = ref(true);
|
||||
|
||||
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
|
||||
@@ -65,7 +70,15 @@ async function loadBalanceStatus(): Promise<void> {
|
||||
onMounted(() => {
|
||||
void loadNotifications();
|
||||
void loadBalanceStatus();
|
||||
void tourLauncher.checkQuery();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.tour,
|
||||
() => {
|
||||
void tourLauncher.checkQuery();
|
||||
},
|
||||
);
|
||||
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
|
||||
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
|
||||
</script>
|
||||
@@ -92,6 +105,12 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
|
||||
<CommandPalette />
|
||||
<JivoWidget />
|
||||
<WelcomeTour />
|
||||
<GuidedTour
|
||||
v-if="tourLauncher.activeTour.value"
|
||||
:steps="tourLauncher.activeTour.value.steps"
|
||||
:active="true"
|
||||
@finish="tourLauncher.finishTour()"
|
||||
/>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -196,7 +196,13 @@ const routes: RouteRecordRaw[] = [
|
||||
// TODO: дополнительный role-guard на super_admin.
|
||||
{
|
||||
path: '/admin',
|
||||
redirect: '/admin/tenants',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/admin/dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('../views/admin/AdminDashboardView.vue'),
|
||||
meta: { layout: 'admin', title: 'Командный центр', requiresAuth: true, devIndex: 20, devLabel: 'Admin Dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/admin/tenants',
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Каталог экскурсий «Показать на портале» (спека ИИ-бота §4).
|
||||
* ИИ шаги НЕ сочиняет — только выбирает готовый сценарий по имени
|
||||
* (frontmatter `tour:` статьи resources/help). Селекторы целей — существующие
|
||||
* data-tour (sidebar: nav-*) и data-testid; target может появиться ПОСЛЕ
|
||||
* действия клиента (открыл диалог) — раннер умеет ждать (см. GuidedTour).
|
||||
*/
|
||||
export interface TourStep {
|
||||
/** Роут, на котором живёт цель шага; раннер переходит туда сам. */
|
||||
route: string;
|
||||
/** CSS-селектор цели подсветки. */
|
||||
target: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TourScenario {
|
||||
name: string;
|
||||
steps: TourStep[];
|
||||
}
|
||||
|
||||
export const TOURS: TourScenario[] = [
|
||||
{
|
||||
name: 'create-project',
|
||||
steps: [
|
||||
{
|
||||
route: '/projects',
|
||||
target: '[data-tour="nav-projects"]',
|
||||
title: 'Раздел «Проекты»',
|
||||
text: 'Здесь живут все ваши проекты — заявки на поток клиентов.',
|
||||
},
|
||||
{
|
||||
route: '/projects',
|
||||
target: '[data-tour="projects-create"]',
|
||||
title: 'Создать проект',
|
||||
text: 'Нажмите эту кнопку — откроется форма нового проекта. Понадобятся название, источник и дневной лимит заявок.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'top-up-balance',
|
||||
steps: [
|
||||
{
|
||||
route: '/billing',
|
||||
target: '[data-tour="nav-billing"]',
|
||||
title: 'Раздел «Биллинг»',
|
||||
text: 'Баланс, история операций и пополнение — всё здесь.',
|
||||
},
|
||||
{
|
||||
route: '/billing',
|
||||
target: '[data-tour="billing-topup"]',
|
||||
title: 'Пополнить баланс',
|
||||
text: 'Нажмите, чтобы выставить счёт на пополнение. После оплаты деньги зачислятся автоматически.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tariffs',
|
||||
steps: [
|
||||
{
|
||||
route: '/billing',
|
||||
target: '[data-tour="nav-billing"]',
|
||||
title: 'Тарифы — в «Биллинге»',
|
||||
text: 'Вы платите только за полученные заявки. Актуальные цены и ваша тарифная ступень — в этом разделе.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'change-source',
|
||||
steps: [
|
||||
{
|
||||
route: '/projects',
|
||||
target: '[data-tour="nav-projects"]',
|
||||
title: 'Смена источника — в «Проектах»',
|
||||
text: 'Откройте нужный проект — в его настройках можно сменить источник без потери заявок.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'notifications',
|
||||
steps: [
|
||||
{
|
||||
route: '/settings',
|
||||
target: '[data-tour="nav-settings"]',
|
||||
title: 'Уведомления — в «Настройках»',
|
||||
text: 'Здесь включаются письма о новых заявках и другие уведомления.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function findTour(name: string): TourScenario | null {
|
||||
if (name === '') return null;
|
||||
return TOURS.find((t) => t.name === name) ?? null;
|
||||
}
|
||||
@@ -21,8 +21,10 @@
|
||||
*
|
||||
* Привязывать к `@update:menu` нужного `v-autocomplete`/`v-select`.
|
||||
*/
|
||||
export function repositionMenuAfterOpen(open: boolean): void {
|
||||
if (!open || typeof window === 'undefined') return;
|
||||
// Ядро: дождаться, пока геометрия последнего открытого меню устаканится, и один
|
||||
// раз послать resize — Vuetify пересчитает позицию по уже стабильной геометрии.
|
||||
function scheduleStabilize(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
let prevLeft = Number.NaN;
|
||||
let stableFrames = 0;
|
||||
@@ -50,3 +52,47 @@ export function repositionMenuAfterOpen(open: boolean): void {
|
||||
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Глобально включает обход бага позиционирования меню Vuetify: один
|
||||
* `MutationObserver` ловит появление любого `.v-overlay.v-menu` в DOM и
|
||||
* запускает стабилизацию позиции. Вешать один раз при запуске приложения —
|
||||
* покрывает все `v-select`/`v-autocomplete`/`v-menu`, текущие и будущие, без
|
||||
* ручной разметки в шаблонах.
|
||||
*
|
||||
* Идемпотентна (повторный вызов — noop). SSR-safe. Возвращает teardown
|
||||
* (отключить наблюдатель — нужно тестам и на случай явной остановки).
|
||||
*/
|
||||
export function installMenuRepositionFix(): () => void {
|
||||
const noop = (): void => {};
|
||||
if (installed) return noop;
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof document === 'undefined' ||
|
||||
typeof MutationObserver === 'undefined' ||
|
||||
!document.body
|
||||
) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
installed = true;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
if (node.matches('.v-overlay.v-menu') || node.querySelector('.v-overlay.v-menu')) {
|
||||
scheduleStabilize();
|
||||
return; // одного запуска на пачку мутаций достаточно
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
installed = false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
|
||||
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" data-tour="billing-topup" @click="topupOpen = true"
|
||||
>Пополнить баланс</v-btn
|
||||
>
|
||||
</header>
|
||||
|
||||
@@ -109,11 +109,11 @@ function applySummary(s: DashboardSummary): void {
|
||||
{
|
||||
label: 'Активные проекты',
|
||||
value: String(s.active_projects.active),
|
||||
// «/ N» и подпись «лимит тарифа» — только если лимит реально задан (>0),
|
||||
// иначе «3 / 0» выглядит сломанным (UI-аудит).
|
||||
unit: s.active_projects.limit > 0 ? `/ ${s.active_projects.limit}` : '',
|
||||
// Лимита по числу проектов нет (ограничение только по балансу/лидам) —
|
||||
// показываем просто количество активных, без «/ N лимит тарифа».
|
||||
unit: '',
|
||||
delta: { dir: 'neutral', text: '' },
|
||||
sub: s.active_projects.limit > 0 ? 'лимит тарифа' : '',
|
||||
sub: '',
|
||||
hint: 'Проекты, которые сейчас собирают заявки.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<h1 class="text-h4">Проекты</h1>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">Создать проект</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" data-tour="projects-create" @click="openCreate">Создать проект</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка → Командный центр (дашборд). Landing SaaS-админки: 4 плитки-светофора
|
||||
* с проваливанием в детали (Уровень 2). Все 4 области — Финансы, Здоровье, Лиды,
|
||||
* Заказ у поставщика — наполнены живыми данными (Этапы 1 + 2).
|
||||
*
|
||||
* Источник дизайна: web/admin-dashboard-mockup.html (Forest-палитра).
|
||||
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
|
||||
* Backend: AdminDashboardController (группа ['saas-admin','admin-db']).
|
||||
*
|
||||
* Клик по строке «внимание»/«топ» → /admin/tenants/{subdomain} (Уровень 3).
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
getDashboardSummary,
|
||||
getDashboardFinance,
|
||||
getDashboardHealth,
|
||||
getDashboardLeads,
|
||||
getDashboardSupply,
|
||||
type DashboardSummary,
|
||||
type FinanceDetail,
|
||||
type HealthDetail,
|
||||
type LeadsDetail,
|
||||
type SupplyDetail,
|
||||
type Light,
|
||||
} from '../../api/adminDashboard';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
type Area = 'fin' | 'health' | 'leads' | 'supply';
|
||||
type Period = 'today' | '7d' | '30d' | '60d' | '90d';
|
||||
|
||||
const period = ref<Period>('7d');
|
||||
const selected = ref<Area>('fin');
|
||||
|
||||
const summary = ref<DashboardSummary | null>(null);
|
||||
const finance = ref<FinanceDetail | null>(null);
|
||||
const health = ref<HealthDetail | null>(null);
|
||||
const leads = ref<LeadsDetail | null>(null);
|
||||
const supply = ref<SupplyDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
const PERIODS: Array<{ value: Period; label: string }> = [
|
||||
{ value: 'today', label: 'Сегодня' },
|
||||
{ value: '7d', label: '7 дней' },
|
||||
{ value: '30d', label: '30 дней' },
|
||||
{ value: '60d', label: '60 дней' },
|
||||
{ value: '90d', label: '90 дней' },
|
||||
];
|
||||
|
||||
/** Светофор-цвет → Vuetify-цвет. */
|
||||
function lightColor(light: Light): string {
|
||||
return light === 'green' ? 'success' : light === 'amber' ? 'warning' : 'error';
|
||||
}
|
||||
|
||||
/** Подпись светофора Финансов на плитке. */
|
||||
function financeLightLabel(): string {
|
||||
const n = summary.value?.finance.negative_balance_count ?? 0;
|
||||
return n > 0 ? `${n} в минусе` : 'в норме';
|
||||
}
|
||||
|
||||
/** Подпись светофора Здоровья на плитке. */
|
||||
function healthLightLabel(): string {
|
||||
return summary.value?.health.light === 'green' ? 'OK' : 'есть проблемы';
|
||||
}
|
||||
|
||||
/** Подпись светофора Лидов на плитке. */
|
||||
function leadsLightLabel(): string {
|
||||
const st = summary.value?.leads.stuck ?? 0;
|
||||
return st > 0 ? `${st} зависших` : 'чисто';
|
||||
}
|
||||
|
||||
/** Подпись светофора Заказа на плитке. */
|
||||
function supplyLightLabel(): string {
|
||||
const m = summary.value?.supply.mismatches ?? 0;
|
||||
return m > 0 ? `${m} рассинхрон` : 'ровно';
|
||||
}
|
||||
|
||||
/** Человеческие названия подсистем здоровья. */
|
||||
const SUBSYSTEM_LABELS: Record<string, string> = {
|
||||
queues: 'Очереди / джобы',
|
||||
scheduler: 'Планировщик',
|
||||
supplier_sync: 'Синхрон с поставщиком',
|
||||
csv_drift: 'Сверка CSV (дрейф)',
|
||||
webhooks: 'Вебхуки',
|
||||
incidents: 'Инциденты',
|
||||
};
|
||||
|
||||
function subsystemLabel(key: string): string {
|
||||
return SUBSYSTEM_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
/** «320000» → «320 000 ₽» (узкие неразрывные пробелы по разрядам). */
|
||||
function rub(value: string | number | null | undefined): string {
|
||||
const n = Math.round(Number(value ?? 0));
|
||||
const sign = n < 0 ? '−' : '';
|
||||
const digits = Math.abs(n)
|
||||
.toString()
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
return `${sign}${digits} ₽`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const [s, f, h, l, sup] = await Promise.all([
|
||||
getDashboardSummary(period.value),
|
||||
getDashboardFinance(period.value),
|
||||
getDashboardHealth(),
|
||||
getDashboardLeads(),
|
||||
getDashboardSupply(),
|
||||
]);
|
||||
summary.value = s;
|
||||
finance.value = f;
|
||||
health.value = h;
|
||||
leads.value = l;
|
||||
supply.value = sup;
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setPeriod(p: Period) {
|
||||
period.value = p;
|
||||
void load();
|
||||
}
|
||||
|
||||
function selectArea(area: Area) {
|
||||
selected.value = area;
|
||||
}
|
||||
|
||||
function openTenant(subdomain: string) {
|
||||
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ period, selected, summary, finance, health, leads, supply, loading, fetchError, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-dashboard pa-6">
|
||||
<!-- Шапка: заголовок + период -->
|
||||
<div class="d-flex align-center justify-space-between mb-1 flex-wrap ga-3">
|
||||
<h1 class="text-h5 font-weight-bold">Командный центр</h1>
|
||||
<div class="period-toggle">
|
||||
<v-btn
|
||||
v-for="p in PERIODS"
|
||||
:key="p.value"
|
||||
:variant="period === p.value ? 'flat' : 'text'"
|
||||
:color="period === p.value ? 'primary' : undefined"
|
||||
size="small"
|
||||
class="text-none"
|
||||
:data-testid="`period-${p.value}`"
|
||||
@click="setPeriod(p.value)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||
Одна картина всего портала. Кликните плитку — провалитесь в детали.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
data-testid="fetch-error-alert"
|
||||
>
|
||||
Не удалось загрузить данные дашборда. Попробуйте обновить.
|
||||
</v-alert>
|
||||
|
||||
<!-- Плитки L1 -->
|
||||
<v-row dense>
|
||||
<!-- ФИНАНСЫ -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card
|
||||
:variant="selected === 'fin' ? 'elevated' : 'outlined'"
|
||||
:class="{ 'tile--sel': selected === 'fin' }"
|
||||
class="tile"
|
||||
data-testid="tile-fin"
|
||||
@click="selectArea('fin')"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<span class="tile__ico">💰</span>
|
||||
<span class="text-subtitle-1 font-weight-bold ml-2">Финансы</span>
|
||||
<v-chip
|
||||
:color="lightColor(summary?.finance.light ?? 'green')"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ financeLightLabel() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Пополнения за период</span>
|
||||
<span class="num text-h6 font-weight-bold">{{ rub(summary?.finance.topups_rub) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Списано за лиды</span>
|
||||
<span class="num font-weight-bold">{{ rub(summary?.finance.charges_rub) }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Активных клиентов</span>
|
||||
<span class="num font-weight-bold">{{ summary?.finance.active_clients ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-medium-emphasis">Новых за период</span>
|
||||
<span class="num font-weight-bold text-success">+{{ summary?.finance.new_clients ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="tile__more">Открыть финансы →</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- ЗДОРОВЬЕ -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card
|
||||
:variant="selected === 'health' ? 'elevated' : 'outlined'"
|
||||
:class="{ 'tile--sel': selected === 'health' }"
|
||||
class="tile"
|
||||
data-testid="tile-health"
|
||||
@click="selectArea('health')"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<span class="tile__ico">❤️</span>
|
||||
<span class="text-subtitle-1 font-weight-bold ml-2">Здоровье портала</span>
|
||||
<v-chip
|
||||
:color="lightColor(summary?.health.light ?? 'green')"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ healthLightLabel() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Ошибок джоб за сутки</span>
|
||||
<span
|
||||
class="num font-weight-bold"
|
||||
:class="{ 'text-error': (summary?.health.job_errors_24h ?? 0) > 0 }"
|
||||
>{{ summary?.health.job_errors_24h ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Синхрон с поставщиком</span>
|
||||
<span class="font-weight-bold">{{ summary?.health.last_sync_status ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-medium-emphasis">Открытых инцидентов</span>
|
||||
<span class="num font-weight-bold">{{ summary?.health.open_incidents ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="tile__more">Открыть здоровье →</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- ЛИДЫ (Этап 2) -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card
|
||||
:variant="selected === 'leads' ? 'elevated' : 'outlined'"
|
||||
:class="{ 'tile--sel': selected === 'leads' }"
|
||||
class="tile"
|
||||
data-testid="tile-leads"
|
||||
@click="selectArea('leads')"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<span class="tile__ico">🎯</span>
|
||||
<span class="text-subtitle-1 font-weight-bold ml-2">Лиды</span>
|
||||
<v-chip
|
||||
:color="lightColor(summary?.leads.light ?? 'green')"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ leadsLightLabel() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Доставлено сегодня</span>
|
||||
<span class="num text-h6 font-weight-bold">{{ summary?.leads.delivered_today ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Получено от поставщика</span>
|
||||
<span class="num font-weight-bold">{{ summary?.leads.received_today ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Зависших</span>
|
||||
<span
|
||||
class="num font-weight-bold"
|
||||
:class="{ 'text-error': (summary?.leads.stuck ?? 0) > 0 }"
|
||||
>{{ summary?.leads.stuck ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-medium-emphasis">Нераспределённых</span>
|
||||
<span class="num font-weight-bold">{{ summary?.leads.unrouted ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="tile__more">Открыть лиды →</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- ЗАКАЗ У ПОСТАВЩИКА (Этап 2) -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card
|
||||
:variant="selected === 'supply' ? 'elevated' : 'outlined'"
|
||||
:class="{ 'tile--sel': selected === 'supply' }"
|
||||
class="tile"
|
||||
data-testid="tile-supply"
|
||||
@click="selectArea('supply')"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<span class="tile__ico">📦</span>
|
||||
<span class="text-subtitle-1 font-weight-bold ml-2">Заказ у поставщика</span>
|
||||
<v-chip
|
||||
:color="lightColor(summary?.supply.light ?? 'green')"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="ml-auto"
|
||||
>
|
||||
{{ supplyLightLabel() }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Просят клиенты (Σ/день)</span>
|
||||
<span class="num text-h6 font-weight-bold">{{ summary?.supply.demand ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Надо по формуле</span>
|
||||
<span class="num font-weight-bold">{{ summary?.supply.formula ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between mb-2">
|
||||
<span class="text-medium-emphasis">Заказали по факту</span>
|
||||
<span class="num font-weight-bold">{{ summary?.supply.ordered ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-medium-emphasis">Групп с рассинхроном</span>
|
||||
<span class="num font-weight-bold">{{ summary?.supply.mismatches ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="tile__more">Открыть заказ →</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- DRILL: ФИНАНСЫ -->
|
||||
<v-card v-if="selected === 'fin'" variant="outlined" class="drill mt-5" data-testid="drill-fin">
|
||||
<v-card-title class="drill__head">💰 Финансы — детали</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row dense class="mb-4">
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Пополнения</div>
|
||||
<div class="kpi__val num">{{ rub(finance?.kpi.topups_rub) }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Списано за лиды</div>
|
||||
<div class="kpi__val num">{{ rub(finance?.kpi.charges_rub) }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Чистый приток</div>
|
||||
<div class="kpi__val num text-success">{{ rub(finance?.kpi.net_inflow_rub) }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Клиентов в минусе</div>
|
||||
<div class="kpi__val num text-error">{{ finance?.kpi.negative_balance_count ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<h4 class="panel__h4">🔴 Требуют внимания (баланс в минусе)</h4>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Клиент</th>
|
||||
<th class="text-right">Баланс</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="t in finance?.attention ?? []"
|
||||
:key="t.id"
|
||||
class="clk"
|
||||
@click="openTenant(t.subdomain)"
|
||||
>
|
||||
<td>{{ t.organization_name }}</td>
|
||||
<td class="text-right num text-error">{{ rub(t.balance_rub) }}</td>
|
||||
</tr>
|
||||
<tr v-if="(finance?.attention?.length ?? 0) === 0">
|
||||
<td colspan="2" class="text-medium-emphasis text-center">Никто не в минусе 🎉</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<h4 class="panel__h4">Топ по обороту (пополнения за период)</h4>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Клиент</th>
|
||||
<th class="text-right">Пополнил</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="t in finance?.top_by_turnover ?? []"
|
||||
:key="t.id"
|
||||
class="clk"
|
||||
@click="openTenant(String(t.id))"
|
||||
>
|
||||
<td>{{ t.organization_name }}</td>
|
||||
<td class="text-right num">{{ rub(t.topped_rub) }}</td>
|
||||
</tr>
|
||||
<tr v-if="(finance?.top_by_turnover?.length ?? 0) === 0">
|
||||
<td colspan="2" class="text-medium-emphasis text-center">Нет пополнений за период</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- DRILL: ЗДОРОВЬЕ -->
|
||||
<v-card v-else-if="selected === 'health'" variant="outlined" class="drill mt-5" data-testid="drill-health">
|
||||
<v-card-title class="drill__head">❤️ Здоровье портала — детали</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row dense>
|
||||
<v-col v-for="s in health?.subsystems ?? []" :key="s.key" cols="12" sm="6" md="4">
|
||||
<div class="sub">
|
||||
<div class="sub__nm">
|
||||
<v-icon :color="lightColor(s.light)" size="12" icon="mdi-circle" class="mr-2" />
|
||||
{{ subsystemLabel(s.key) }}
|
||||
</div>
|
||||
<div class="sub__meta">{{ s.detail }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- DRILL: ЛИДЫ -->
|
||||
<v-card v-else-if="selected === 'leads'" variant="outlined" class="drill mt-5" data-testid="drill-leads">
|
||||
<v-card-title class="drill__head">🎯 Лиды — детали</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row dense>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Доставлено сегодня</div>
|
||||
<div class="kpi__val num">{{ leads?.kpi.delivered_today ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Получено от поставщика</div>
|
||||
<div class="kpi__val num">{{ leads?.kpi.received_today ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Зависших</div>
|
||||
<div class="kpi__val num" :class="{ 'text-error': (leads?.kpi.stuck ?? 0) > 0 }">
|
||||
{{ leads?.kpi.stuck ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Нераспределённых</div>
|
||||
<div class="kpi__val num">{{ leads?.kpi.unrouted ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<p class="text-medium-emphasis text-body-2 mt-2">
|
||||
«Доставлено» — сделки, созданные клиентам сегодня. «Получено» — лиды, пришедшие от поставщика
|
||||
сегодня. «Зависшие» — лиды без распределения дольше 4 часов (если их много — проблема синхронизации).
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА -->
|
||||
<v-card v-else variant="outlined" class="drill mt-5" data-testid="drill-supply">
|
||||
<v-card-title class="drill__head">📦 Заказ у поставщика — детали</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert variant="tonal" density="compact" class="mb-4" type="info">
|
||||
Всего у поставщика активных заказов: <b>{{ supply?.total_orders ?? 0 }}</b>
|
||||
на <b>{{ supply?.total_limit ?? 0 }}</b> лидов/день.
|
||||
Сверка ниже — по снимку маршрутизации на <b>{{ supply?.snapshot_date ?? '—' }}</b>
|
||||
(снимок делается каждый день в 18:02 МСК; в нём только проекты, активные на тот день).
|
||||
</v-alert>
|
||||
<v-row dense class="mb-4">
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Просят клиенты</div>
|
||||
<div class="kpi__val num">{{ supply?.totals.demand ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Надо по формуле</div>
|
||||
<div class="kpi__val num">{{ supply?.totals.formula ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Заказали по факту</div>
|
||||
<div class="kpi__val num">{{ supply?.totals.ordered ?? 0 }}</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" md="3">
|
||||
<div class="kpi">
|
||||
<div class="kpi__lab">Рассинхронов</div>
|
||||
<div class="kpi__val num" :class="{ 'text-error': (supply?.totals.mismatches ?? 0) > 0 }">
|
||||
{{ supply?.totals.mismatches ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<h4 class="panel__h4">По группам: спрос → формула → факт</h4>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Группа</th>
|
||||
<th class="text-right">Просят</th>
|
||||
<th class="text-right">Формула</th>
|
||||
<th class="text-right">Факт</th>
|
||||
<th class="text-right">Совпадает?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier">
|
||||
<td>{{ g.identifier }} <span class="text-medium-emphasis">({{ g.signal_type }})</span></td>
|
||||
<td class="text-right num">{{ g.demand }}</td>
|
||||
<td class="text-right num">{{ g.formula }}</td>
|
||||
<td class="text-right num">{{ g.ordered }}</td>
|
||||
<td class="text-right">
|
||||
<v-chip :color="g.in_sync ? 'success' : 'error'" size="x-small" variant="tonal">
|
||||
{{ g.in_sync ? 'да' : 'рассинхрон' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="(supply?.groups?.length ?? 0) === 0">
|
||||
<td colspan="5" class="text-medium-emphasis text-center">
|
||||
Нет данных снимка маршрутизации (снимок делается в 18:02 МСК).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<p class="text-medium-emphasis text-body-2 mt-2">
|
||||
Формула заказа: max самого крупного клиента и ⌈сумма спроса ÷ 3⌉ — лид перепродаётся до 3 клиентов.
|
||||
Рассинхрон = факт ≠ формула.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.period-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
}
|
||||
.tile {
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.tile:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.tile--sel {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
.tile__ico {
|
||||
font-size: 18px;
|
||||
}
|
||||
.tile__more {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.drill__head {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.kpi {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.kpi__lab {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.kpi__val {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.panel__h4 {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sub {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
.sub__nm {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.sub__meta {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.clk:hover {
|
||||
background: rgba(15, 110, 86, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -59,6 +59,55 @@ async function setExportMode(mode: ExportMode): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot) ---
|
||||
|
||||
const sourceEditEnabled = ref(false);
|
||||
const sourceEditError = ref<string | null>(null);
|
||||
const sourceEditSaving = ref(false);
|
||||
const sourceEditConfirmOpen = ref(false);
|
||||
const pendingSourceEditValue = ref(false);
|
||||
// VSwitch флипает внутреннее состояние по клику; бамп ключа ре-маунтит тумблер,
|
||||
// чтобы он вернулся к фактическому sourceEditEnabled после отмены/ошибки.
|
||||
const sourceEditSwitchKey = ref(0);
|
||||
|
||||
async function loadSourceEditFlag(): Promise<void> {
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/supplier-integration/source-edit-flag');
|
||||
sourceEditEnabled.value = data?.enabled === true;
|
||||
} catch {
|
||||
sourceEditError.value = 'Не удалось загрузить переключатель.';
|
||||
}
|
||||
}
|
||||
|
||||
// Тумблер привязан к sourceEditEnabled один-в-один; запрос смены открывает
|
||||
// подтверждение, фактическое значение меняется только после «Подтвердить».
|
||||
function onSourceEditToggleRequest(val: boolean | null): void {
|
||||
pendingSourceEditValue.value = val === true;
|
||||
sourceEditConfirmOpen.value = true;
|
||||
}
|
||||
|
||||
function cancelSourceEditToggle(): void {
|
||||
sourceEditConfirmOpen.value = false;
|
||||
sourceEditSwitchKey.value++; // вернуть тумблер к фактическому состоянию
|
||||
}
|
||||
|
||||
async function confirmSourceEditToggle(): Promise<void> {
|
||||
sourceEditConfirmOpen.value = false;
|
||||
sourceEditSaving.value = true;
|
||||
sourceEditError.value = null;
|
||||
try {
|
||||
const { data } = await axios.post('/api/admin/supplier-integration/source-edit-flag', {
|
||||
enabled: pendingSourceEditValue.value,
|
||||
});
|
||||
sourceEditEnabled.value = data?.enabled === true;
|
||||
} catch {
|
||||
sourceEditError.value = 'Не удалось сохранить переключатель.';
|
||||
} finally {
|
||||
sourceEditSaving.value = false;
|
||||
sourceEditSwitchKey.value++; // синхронизировать тумблер с фактом (вкл. при ошибке)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -196,6 +245,7 @@ onMounted(() => {
|
||||
void load();
|
||||
void loadManualQueue();
|
||||
void loadExportMode();
|
||||
void loadSourceEditFlag();
|
||||
void loadSyncRuns();
|
||||
});
|
||||
</script>
|
||||
@@ -233,6 +283,63 @@ onMounted(() => {
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mb-4" data-testid="source-edit-flag-card">
|
||||
<v-card-title>Разблокировка смены источника</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert v-if="sourceEditError" type="error" density="compact" class="mb-3">
|
||||
{{ sourceEditError }}
|
||||
</v-alert>
|
||||
<v-switch
|
||||
:key="sourceEditSwitchKey"
|
||||
:model-value="sourceEditEnabled"
|
||||
:loading="sourceEditSaving"
|
||||
:disabled="sourceEditSaving"
|
||||
color="primary"
|
||||
hide-details
|
||||
inset
|
||||
data-testid="source-edit-flag-switch"
|
||||
:label="sourceEditEnabled ? 'Включена' : 'Выключена'"
|
||||
@update:model-value="onSourceEditToggleRequest"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-1 mb-0">
|
||||
ВКЛ — клиенты могут менять источник проекта без потери лидов (маршрутизация по слепку).
|
||||
ВЫКЛ — смена источника заблокирована. Откат безопасен в любой момент.
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="sourceEditConfirmOpen" max-width="480" data-testid="source-edit-confirm">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ pendingSourceEditValue ? 'Включить' : 'Выключить' }} разблокировку смены источника?
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<template v-if="pendingSourceEditValue">
|
||||
Клиенты смогут менять источник проекта без потери лидов (матч по слепку).
|
||||
Рекомендуется сутки понаблюдать по «Вечерней заливке», что лиды доезжают.
|
||||
</template>
|
||||
<template v-else>
|
||||
Вернётся прежнее поведение: смена источника заблокирована. Откат безопасен.
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="source-edit-confirm-cancel" @click="cancelSourceEditToggle">
|
||||
Отмена
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="sourceEditSaving"
|
||||
data-testid="source-edit-confirm-apply"
|
||||
@click="confirmSourceEditToggle"
|
||||
>
|
||||
Подтвердить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-card class="mb-4" data-testid="sync-runs-card">
|
||||
<v-card-title>Вечерняя заливка проектов поставщику</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
@@ -83,6 +83,26 @@
|
||||
📣 Лидерра поставит проект в сбор сразу после создания. Первые лиды пойдут с {{ leadStart }}.
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex align-center mb-3 text-body-2 text-medium-emphasis" data-testid="np-boost-hint">
|
||||
<span>Как увеличить количество сделок</span>
|
||||
<v-tooltip
|
||||
text="Ваш лимит распределяется на нескольких поставщиков равномерно. Даже если лимит не выбирается полностью, просто увеличьте лимит — и сделок придёт больше."
|
||||
location="top"
|
||||
max-width="280"
|
||||
>
|
||||
<template #activator="{ props: tip }">
|
||||
<v-icon
|
||||
v-bind="tip"
|
||||
size="14"
|
||||
class="src-hint ml-1"
|
||||
icon="mdi-help-circle-outline"
|
||||
aria-label="Как увеличить количество сделок"
|
||||
tabindex="0"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center mb-1 text-body-2 text-medium-emphasis">
|
||||
<span>Откуда собирать заявки</span>
|
||||
<v-tooltip
|
||||
@@ -211,7 +231,6 @@
|
||||
data-testid="regions-autocomplete"
|
||||
:error-messages="errors.regions"
|
||||
@update:model-value="onRegionsChange"
|
||||
@update:menu="repositionMenuAfterOpen"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
@@ -316,7 +335,6 @@ import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/clie
|
||||
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
|
||||
import { firstLeadDate, formatLeadDate } from '../../utils/leadDate';
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
||||
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
|
||||
|
||||
+11
-1
@@ -4,6 +4,7 @@ use App\Jobs\SendNewLeadsDigestJob;
|
||||
use App\Jobs\SnapshotProjectRoutingJob;
|
||||
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\FlushDeferredOnlineSyncJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
@@ -43,7 +44,7 @@ Schedule::command('projects:reset-delivered-today')
|
||||
|
||||
// Task 4.2: досыл отложенной онлайн-очереди в 00:05 МСК (после сброса счётчиков в 00:00,
|
||||
// вне окна 18:00→00:00 — отложенные правки уходят поставщику немедленно).
|
||||
Schedule::job(new \App\Jobs\Supplier\FlushDeferredOnlineSyncJob)
|
||||
Schedule::job(new FlushDeferredOnlineSyncJob)
|
||||
->dailyAt('00:05')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null))
|
||||
@@ -185,3 +186,12 @@ Schedule::command('scheduler:check-heartbeats')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
|
||||
|
||||
// База знаний ИИ-бота: ночная переиндексация статей resources/help
|
||||
// (спека 2026-07-02-jivo-ai-support-bot-design §3). Изменил статью — ночью бот знает.
|
||||
Schedule::command('help:rebuild-knowledge')
|
||||
->dailyAt('04:30')
|
||||
->timezone('Europe/Moscow')
|
||||
->onOneServer()
|
||||
->onSuccess(fn () => $hb->recordRunResult('help:rebuild-knowledge', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('help:rebuild-knowledge', false, 'Command failed', null));
|
||||
|
||||
+22
-1
@@ -104,7 +104,18 @@ Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobCon
|
||||
// app-слой (REMOTE_USER ∈ ADMIN_ALLOWED_USERS, закрывает обходы фронт-контроллера)
|
||||
// + запрет входа во время impersonation. Реальный Yandex 360 SSO — TODO под Б-1+DO-4.
|
||||
// admin_user_id для audit — трейт ResolvesAdminUserId (отдельная зона).
|
||||
Route::middleware('saas-admin')->group(function () {
|
||||
// admin-db (UseAdminConnection) — ПОСЛЕ saas-admin: на время admin-запроса
|
||||
// default-подключение = pgsql_admin (роль crm_admin_user, srv_bypass), чтобы
|
||||
// AdminTenants/AdminBillingController видели все тенанты после переезда на
|
||||
// Managed PG (Путь А). Контроллеры на pgsql_supplier не затрагиваются.
|
||||
Route::middleware(['saas-admin', 'admin-db'])->group(function () {
|
||||
// Командный центр (дашборд) — read-only агрегаты L1 + L2.
|
||||
Route::get('/api/admin/dashboard', 'App\Http\Controllers\Api\AdminDashboardController@summary');
|
||||
Route::get('/api/admin/dashboard/finance', 'App\Http\Controllers\Api\AdminDashboardController@finance');
|
||||
Route::get('/api/admin/dashboard/health', 'App\Http\Controllers\Api\AdminDashboardController@health');
|
||||
Route::get('/api/admin/dashboard/leads', 'App\Http\Controllers\Api\AdminDashboardController@leads');
|
||||
Route::get('/api/admin/dashboard/supply', 'App\Http\Controllers\Api\AdminDashboardController@supply');
|
||||
|
||||
// SaaS-admin impersonation flow (Ю-1). Авторизация — через гейт группы (EnsureSaasAdmin).
|
||||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||||
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
|
||||
@@ -188,6 +199,10 @@ Route::middleware('saas-admin')->group(function () {
|
||||
Route::get('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getExportMode');
|
||||
Route::post('/api/admin/supplier-integration/export-mode', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setExportMode');
|
||||
|
||||
// Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot).
|
||||
Route::get('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@getSourceEditFlag');
|
||||
Route::post('/api/admin/supplier-integration/source-edit-flag', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@setSourceEditFlag');
|
||||
|
||||
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
|
||||
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
|
||||
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
|
||||
@@ -321,6 +336,12 @@ Route::post('/api/webhook/supplier', 'App\Http\Controllers\Api\SupplierWebhookCo
|
||||
Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive')
|
||||
->where('secret', '[A-Za-z0-9_\-]+');
|
||||
|
||||
// ИИ-бот техподдержки: события Jivo Bot API (CLIENT_MESSAGE и служебные).
|
||||
// Защита — секрет в URL по образцу supplier-webhook; ack мгновенный, работа в джобе
|
||||
// (спека docs/superpowers/specs/2026-07-02-jivo-ai-support-bot-design.md §5).
|
||||
Route::post('/api/webhook/jivo/{secret}', 'App\Http\Controllers\Api\JivoBotController@receive')
|
||||
->where('secret', '[A-Za-z0-9_\-]+');
|
||||
|
||||
// Платёжный webhook (ЮKassa). Публичный, под маской api/webhook/* → CSRF-exempt.
|
||||
// Подлинность — server-to-server сверкой статуса (не доверяем телу). Plan billing-yookassa Task 7.
|
||||
Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookController@receive');
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Concerns;
|
||||
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Share PDO между pgsql и pgsql_admin connections в тестах.
|
||||
*
|
||||
* Зачем: middleware UseAdminConnection (alias admin-db) на группе saas-admin
|
||||
* переключает default-подключение на pgsql_admin (роль crm_admin_user). В тестах
|
||||
* DatabaseTransactions оборачивает каждый connection в свою транзакцию: данные,
|
||||
* засеянные через default Tenant::factory() ($pgsql), не видны с pgsql_admin
|
||||
* connection до commit'а → admin-эндпоинты в тестах видят 0 строк / 404.
|
||||
* Sharing PDO означает — обе connection используют ту же PDO session → одну
|
||||
* транзакцию, и засеянные данные видны admin-контроллеру.
|
||||
*
|
||||
* На production обе connection реальные separate PDO; pgsql_admin (srv_bypass)
|
||||
* видит все тенанты по READ COMMITTED. Этот trait — только для test-окружения.
|
||||
*
|
||||
* Зеркало [[SharesSupplierPdo]] для pgsql_admin. Применяется глобально к Feature
|
||||
* suite (см. tests/Pest.php), т.к. admin-db висит на всей группе saas-admin —
|
||||
* любой admin-тест (текущий и будущий) получает cross-connection visibility без
|
||||
* per-file opt-in. Для не-admin тестов инертен (pgsql_admin просто не запрашивают).
|
||||
*/
|
||||
trait SharesAdminPdo
|
||||
{
|
||||
protected function setUpSharesAdminPdo(): void
|
||||
{
|
||||
if (! config()->has('database.connections.pgsql_admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultConnection = DB::connection('pgsql');
|
||||
$adminConnection = DB::connection('pgsql_admin');
|
||||
$adminConnection->setPdo($defaultConnection->getPdo());
|
||||
$adminConnection->setReadPdo($defaultConnection->getReadPdo());
|
||||
|
||||
// Синхронизируем уровень вложенности транзакции: DatabaseTransactions уже
|
||||
// открыл транзакцию на pgsql (тот же PDO) к моменту setUp. Без синхронизации
|
||||
// pgsql_admin считает transactions=0 и при ->transaction() зовёт
|
||||
// PDO->beginTransaction() на уже активной транзакции → PDOException
|
||||
// "There is already an active transaction" (например AdminTenantsController::
|
||||
// updateBalance). С синхронизацией вложенный transaction() делает SAVEPOINT.
|
||||
$level = $defaultConnection->transactionLevel();
|
||||
if ($level > 0) {
|
||||
$prop = new \ReflectionProperty(Connection::class, 'transactions');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue($adminConnection, $level);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
it('applies admin-db middleware to the admin api route group', function () {
|
||||
$route = collect(Route::getRoutes()->getRoutes())
|
||||
->first(fn ($r) => $r->uri() === 'api/admin/tenants');
|
||||
|
||||
expect($route)->not->toBeNull();
|
||||
expect($route->gatherMiddleware())->toContain('admin-db');
|
||||
// saas-admin по-прежнему в пайплайне (гейт не потерян)
|
||||
expect($route->gatherMiddleware())->toContain('saas-admin');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::table('balance_transactions')->delete();
|
||||
DB::table('tenants')->delete();
|
||||
});
|
||||
|
||||
it('GET /api/admin/dashboard/finance returns KPIs + attention + top tables', function () {
|
||||
$neg = DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'neg', 'organization_name' => 'Negative', 'contact_email' => 'n@x.ru',
|
||||
'status' => 'active', 'is_trial' => false, 'balance_rub' => -100, 'balance_leads' => 0,
|
||||
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('balance_transactions')->insert([
|
||||
'tenant_id' => $neg, 'type' => 'topup', 'amount_rub' => 5000, 'created_at' => now(),
|
||||
]);
|
||||
|
||||
$res = $this->getJson('/api/admin/dashboard/finance?period=30d');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'kpi' => ['topups_rub', 'charges_rub', 'net_inflow_rub', 'negative_balance_count'],
|
||||
'attention', 'top_by_turnover', 'period',
|
||||
]);
|
||||
expect($res->json('kpi.negative_balance_count'))->toBe(1);
|
||||
expect(collect($res->json('attention'))->pluck('organization_name'))->toContain('Negative');
|
||||
expect(collect($res->json('top_by_turnover'))->pluck('organization_name'))->toContain('Negative');
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('GET /api/admin/dashboard/health returns 6 subsystems with light', function () {
|
||||
$res = $this->getJson('/api/admin/dashboard/health');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'subsystems' => [['key', 'light', 'detail']],
|
||||
'overall_light',
|
||||
]);
|
||||
$keys = collect($res->json('subsystems'))->pluck('key')->all();
|
||||
expect($keys)->toContain('queues', 'scheduler', 'supplier_sync', 'csv_drift', 'webhooks', 'incidents');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('GET /api/admin/dashboard/leads возвращает KPI лидов', function () {
|
||||
$tenant = DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'leadsacme', 'organization_name' => 'Acme', 'contact_email' => 'a@acme.ru',
|
||||
'status' => 'active', 'is_trial' => false, 'balance_rub' => 0, 'balance_leads' => 0,
|
||||
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$supplierProjectId = DB::table('supplier_projects')->insertGetId([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'leads-x.ru',
|
||||
'current_limit' => 10, 'sync_status' => 'ok', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('supplier_leads')->insert([
|
||||
['supplier_project_id' => $supplierProjectId, 'platform' => 'B1', 'raw_payload' => '{}',
|
||||
'phone' => '79990000001', 'received_at' => now(), 'processed_at' => now(), 'deals_created_count' => 1],
|
||||
['supplier_project_id' => $supplierProjectId, 'platform' => 'B1', 'raw_payload' => '{}',
|
||||
'phone' => '79990000002', 'received_at' => now()->subHours(6), 'processed_at' => null, 'deals_created_count' => null],
|
||||
]);
|
||||
|
||||
$res = $this->getJson('/api/admin/dashboard/leads');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'light',
|
||||
'kpi' => ['delivered_today', 'received_today', 'stuck', 'unrouted'],
|
||||
]);
|
||||
expect($res->json('kpi.stuck'))->toBe(1); // 1 зависший (6ч, не обработан)
|
||||
expect($res->json('kpi.unrouted'))->toBe(1); // 1 в очереди
|
||||
expect($res->json('light'))->toBe('red'); // есть зависший
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Чистые счётчики: убираем seed/прошлые данные (FK-порядок: сначала проводки).
|
||||
DB::table('balance_transactions')->delete();
|
||||
DB::table('tenants')->delete();
|
||||
});
|
||||
|
||||
it('GET /api/admin/dashboard returns finance + health tiles', function () {
|
||||
$tenant = DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'acme', 'organization_name' => 'Acme', 'contact_email' => 'a@acme.ru',
|
||||
'status' => 'active', 'is_trial' => false, 'balance_rub' => -500, 'balance_leads' => 0,
|
||||
'chargeback_unrecovered_rub' => 0, 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
DB::table('balance_transactions')->insert([
|
||||
['tenant_id' => $tenant, 'type' => 'topup', 'amount_rub' => 10000, 'created_at' => now()],
|
||||
['tenant_id' => $tenant, 'type' => 'lead_charge', 'amount_rub' => -3000, 'created_at' => now()],
|
||||
]);
|
||||
|
||||
$res = $this->getJson('/api/admin/dashboard?period=30d');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'finance' => ['topups_rub', 'charges_rub', 'active_clients', 'new_clients', 'negative_balance_count', 'light'],
|
||||
'health' => ['light', 'open_incidents', 'last_sync_status'],
|
||||
'period',
|
||||
]);
|
||||
expect($res->json('finance.negative_balance_count'))->toBe(1);
|
||||
expect($res->json('finance.light'))->toBe('red');
|
||||
});
|
||||
|
||||
it('summary включает плитки leads и supply', function () {
|
||||
$res = $this->getJson('/api/admin/dashboard?period=30d');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'finance' => ['light'],
|
||||
'health' => ['light'],
|
||||
'leads' => ['light', 'delivered_today', 'received_today', 'stuck', 'unrouted'],
|
||||
'supply' => ['light', 'demand', 'formula', 'ordered', 'mismatches', 'total_orders', 'total_limit'],
|
||||
'period',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('GET /api/admin/dashboard/supply возвращает группы и итоги', function () {
|
||||
// supplier_projects не партиционирован — сеем напрямую. project_routing_snapshots
|
||||
// партиционирована по дате → в тесте не сеем (контракт ответа проверяем; формула
|
||||
// покрыта unit-тестом SupplyReconciliation).
|
||||
DB::table('supplier_projects')->insert([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'demo-x.ru',
|
||||
'current_limit' => 50, 'sync_status' => 'ok',
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$res = $this->getJson('/api/admin/dashboard/supply');
|
||||
|
||||
$res->assertOk();
|
||||
$res->assertJsonStructure([
|
||||
'snapshot_date',
|
||||
'light',
|
||||
'totals' => ['demand', 'formula', 'ordered', 'mismatches'],
|
||||
'total_orders',
|
||||
'total_limit',
|
||||
'groups',
|
||||
]);
|
||||
expect($res->json('light'))->toBeIn(['green', 'red']);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// Тумблер «Разблокировка смены источника» (флаг routing_match_by_snapshot) на экране
|
||||
// «Интеграция с поставщиком» — чтобы владелец включал/выключал мышкой без правки БД.
|
||||
// EnsureSaasAdmin — стаб в testing; actingAs нужен для прохода auth+admin middleware.
|
||||
|
||||
it('GET source-edit-flag returns false when flag absent', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->delete();
|
||||
|
||||
$this->getJson('/api/admin/supplier-integration/source-edit-flag')
|
||||
->assertOk()
|
||||
->assertJson(['enabled' => false]);
|
||||
});
|
||||
|
||||
it('GET source-edit-flag returns true when flag set', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'routing_match_by_snapshot'],
|
||||
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
$this->getJson('/api/admin/supplier-integration/source-edit-flag')
|
||||
->assertOk()
|
||||
->assertJson(['enabled' => true]);
|
||||
});
|
||||
|
||||
it('POST source-edit-flag enables the flag', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->delete();
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/source-edit-flag', ['enabled' => true])
|
||||
->assertOk()
|
||||
->assertJson(['enabled' => true]);
|
||||
|
||||
expect(DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value'))
|
||||
->toBe('true');
|
||||
});
|
||||
|
||||
it('POST source-edit-flag disables the flag', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'routing_match_by_snapshot'],
|
||||
['value' => 'true', 'type' => 'bool', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/source-edit-flag', ['enabled' => false])
|
||||
->assertOk()
|
||||
->assertJson(['enabled' => false]);
|
||||
|
||||
expect(DB::table('system_settings')->where('key', 'routing_match_by_snapshot')->value('value'))
|
||||
->toBe('false');
|
||||
});
|
||||
|
||||
it('POST source-edit-flag rejects non-boolean', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/supplier-integration/source-edit-flag', ['enabled' => 'maybe'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
@@ -322,3 +322,38 @@ it('audit:rebuild-chain handles single-row partition (first row of tenant) ко
|
||||
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Single-row per-tenant partition должен остаться intact');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Managed PG (Путь А): пересчёт аудита БЕЗ session_replication_role (superuser-only).
|
||||
// audit_block_mutation должен пропускать мутацию по метке app.audit_rebuild='on'
|
||||
// (+ membership в crm_migrator / superuser), а без метки — запрещать (append-only).
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
it('audit_block_mutation чтит метку app.audit_rebuild (без session_replication_role)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
DB::table('activity_log')->insert([
|
||||
'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
|
||||
'event' => 'flag.test', 'context' => null, 'created_at' => now(),
|
||||
]);
|
||||
$id = (int) DB::table('activity_log')->where('tenant_id', $tenant->id)->orderByDesc('id')->value('id');
|
||||
|
||||
// БЕЗ метки — UPDATE аудита запрещён (append-only). В savepoint, чтобы внешняя
|
||||
// транзакция теста пережила ожидаемую ошибку PostgreSQL.
|
||||
$blocked = false;
|
||||
try {
|
||||
DB::transaction(function () use ($id) {
|
||||
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = {$id}");
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
$blocked = true;
|
||||
}
|
||||
expect($blocked)->toBeTrue('UPDATE аудита без метки должен быть запрещён');
|
||||
|
||||
// С меткой app.audit_rebuild='on' — UPDATE проходит, БЕЗ session_replication_role.
|
||||
DB::transaction(function () use ($id) {
|
||||
DB::statement("SET LOCAL app.audit_rebuild = 'on'");
|
||||
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = {$id}");
|
||||
});
|
||||
expect(true)->toBeTrue(); // дошли без исключения = метка сработала
|
||||
});
|
||||
|
||||
@@ -9,8 +9,14 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
// SharesSupplierPdo: регистрация (RegistrationService) пишет users/tenants/
|
||||
// email_verifications через BYPASSRLS-подключение pgsql_supplier. Без шаринга PDO
|
||||
// эти записи коммитятся мимо DatabaseTransactions и не откатываются — тест
|
||||
// перестаёт быть идемпотентным (повторный прогон/«грязная» БД → 422 «email уже
|
||||
// существует»). Шаринг PDO кладёт supplier-записи в ту же откатываемую транзакцию.
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Reset the Auth manager's default guard and cached guard instances back to
|
||||
|
||||
@@ -10,8 +10,14 @@ use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
// SharesSupplierPdo: регистрация (RegistrationService) пишет users/tenants/
|
||||
// email_verifications через BYPASSRLS-подключение pgsql_supplier. Без шаринга PDO
|
||||
// эти записи коммитятся мимо DatabaseTransactions и не откатываются — тест
|
||||
// перестаёт быть идемпотентным (повторный прогон/«грязная» БД → 422 «email уже
|
||||
// существует»). Шаринг PDO кладёт supplier-записи в ту же откатываемую транзакцию.
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('logout writes auth_log event=logout', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -18,18 +18,27 @@ it('создаёт saas_transactions(pending) и возвращает confirmati
|
||||
|
||||
// legal_entities.legal_entity_id NOT NULL REFERENCES legal_entities(id)
|
||||
$legalEntity = LegalEntity::create([
|
||||
'code' => 'test_le_' . uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO',
|
||||
'code' => 'test_le_'.uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO',
|
||||
'inn' => '7700000000',
|
||||
]);
|
||||
|
||||
$gw = PaymentGateway::create([
|
||||
'code' => 'yookassa_' . uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa',
|
||||
'code' => 'yookassa_'.uniqid(), 'name' => 'ЮKassa', 'driver' => 'yookassa',
|
||||
'legal_entity_id' => $legalEntity->id, 'config' => '', 'is_active' => true,
|
||||
'accepts_methods' => ['card', 'sbp'], 'min_amount_rub' => '100.00',
|
||||
]);
|
||||
|
||||
$fakeDriver = Mockery::mock(PaymentGatewayDriver::class);
|
||||
// Чек 54-ФЗ обязателен на стороне магазина ЮKassa — без него платёж отклоняется
|
||||
// 400 "Receipt is missing" (инцидент 26.06.2026). Гарантируем, что receipt передаётся.
|
||||
$fakeDriver->shouldReceive('createPayment')->once()
|
||||
->withArgs(function ($gw, $amount, $idemp, $returnUrl, $receipt) {
|
||||
return is_array($receipt)
|
||||
&& ! empty($receipt['customer']['email'])
|
||||
&& ($receipt['items'][0]['vat_code'] ?? null) === 1
|
||||
&& ($receipt['items'][0]['amount']['value'] ?? null) === '500.00'
|
||||
&& ($receipt['items'][0]['payment_subject'] ?? null) === 'service';
|
||||
})
|
||||
->andReturn(new CreatePaymentResult('pay_abc', 'https://yoomoney.ru/checkout/pay_abc'));
|
||||
|
||||
$service = new OnlineTopupService($fakeDriver);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use App\Services\Bot\BotAnswerService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.yandexgpt', [
|
||||
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
|
||||
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
|
||||
'timeout_seconds' => 8,
|
||||
]);
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project',
|
||||
'topics' => 'создать проект', 'chunk_index' => 0,
|
||||
'content' => 'Проект — это заявка на поток клиентов.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('стоп-тема (мой баланс) → эскалация без похода в LLM', function () {
|
||||
Http::fake();
|
||||
|
||||
$answer = app(BotAnswerService::class)->answer('какой у меня баланс?');
|
||||
|
||||
expect($answer->escalate)->toBeTrue()
|
||||
->and($answer->text)->toContain('специалисту');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('просьба позвать человека → эскалация', function () {
|
||||
Http::fake();
|
||||
|
||||
expect(app(BotAnswerService::class)->answer('позовите оператора')->escalate)->toBeTrue();
|
||||
});
|
||||
|
||||
it('вопрос не по базе (пустой поиск) → честное «не знаю» + эскалация', function () {
|
||||
Http::fake();
|
||||
|
||||
$answer = app(BotAnswerService::class)->answer('какая погода в москве');
|
||||
|
||||
expect($answer->escalate)->toBeTrue();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('обычный вопрос → ответ LLM по контексту, без эскалации', function () {
|
||||
Http::fake([
|
||||
'llm.api.cloud.yandex.net/*' => Http::response([
|
||||
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это заявка на поток клиентов.']]]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$answer = app(BotAnswerService::class)->answer('что такое проект?');
|
||||
|
||||
expect($answer->escalate)->toBeFalse()
|
||||
->and($answer->text)->toContain('Проект')
|
||||
->and($answer->matchedChunkIds)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('tour-ссылка добавляется только при включённом tours_enabled', function () {
|
||||
config()->set('services.jivo_bot.tours_enabled', true);
|
||||
config()->set('app.url', 'https://liderra.ru');
|
||||
Http::fake([
|
||||
'llm.api.cloud.yandex.net/*' => Http::response([
|
||||
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$withTours = app(BotAnswerService::class)->answer('что такое проект?');
|
||||
expect($withTours->text)->toContain('https://liderra.ru/?tour=create-project');
|
||||
|
||||
config()->set('services.jivo_bot.tours_enabled', false);
|
||||
$without = app(BotAnswerService::class)->answer('что такое проект?');
|
||||
expect($without->text)->not->toContain('?tour=');
|
||||
});
|
||||
|
||||
it('LLM недоступен → эскалация', function () {
|
||||
Http::fake(['llm.api.cloud.yandex.net/*' => Http::response('err', 500)]);
|
||||
|
||||
expect(app(BotAnswerService::class)->answer('что такое проект?')->escalate)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('bot_dialogs принимает запись входа и выхода', function () {
|
||||
DB::table('bot_dialogs')->insert([
|
||||
'jivo_chat_id' => 'chat-1',
|
||||
'direction' => 'in',
|
||||
'message' => 'что такое проект?',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
DB::table('bot_dialogs')->insert([
|
||||
'jivo_chat_id' => 'chat-1',
|
||||
'direction' => 'out',
|
||||
'message' => 'Проект — это…',
|
||||
'matched_chunks' => json_encode([1, 2]),
|
||||
'latency_ms' => 2100,
|
||||
'escalated' => false,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
expect(DB::table('bot_dialogs')->where('jivo_chat_id', 'chat-1')->count())->toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Bot\ProcessJivoMessageJob;
|
||||
use App\Models\BotDialog;
|
||||
use App\Models\KnowledgeChunk;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('наша часть тракта (без сети) укладывается в 500 мс на вопрос', function () {
|
||||
config()->set('services.jivo_bot.outbound_url', ''); // исходящие в лог
|
||||
config()->set('services.yandexgpt', [
|
||||
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
|
||||
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
|
||||
'timeout_seconds' => 8,
|
||||
]);
|
||||
Http::fake([
|
||||
'llm.api.cloud.yandex.net/*' => Http::response([
|
||||
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Ответ.']]]],
|
||||
]),
|
||||
]);
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => "help/a{$i}.md", 'title' => "Статья {$i}", 'tour' => null,
|
||||
'topics' => 'проект, баланс, тариф', 'chunk_index' => 0,
|
||||
'content' => str_repeat("Текст про проект и баланс номер {$i}. ", 30),
|
||||
]);
|
||||
}
|
||||
|
||||
$latencies = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
(new ProcessJivoMessageJob("chat-{$i}", 'c', 'что такое проект?'))->handle();
|
||||
$latencies[] = (int) BotDialog::where('jivo_chat_id', "chat-{$i}")
|
||||
->where('direction', 'out')->value('latency_ms');
|
||||
}
|
||||
|
||||
sort($latencies);
|
||||
$p95 = $latencies[(int) floor(count($latencies) * 0.95) - 1] ?? end($latencies);
|
||||
|
||||
// Бюджет спеки §6: поиск ≤300мс + сборка/журнал ≤200мс. LLM (до 3с) и сеть
|
||||
// Jivo (до 0.5с) — вне нашего кода, замоканы; живой p95 — на приёмке.
|
||||
expect($p95)->toBeLessThan(500);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('перечитывает resources/help и заполняет knowledge_chunks с нуля', function () {
|
||||
// Мусорная строка от прошлой индексации — должна исчезнуть (полная перезаливка).
|
||||
DB::table('knowledge_chunks')->insert([
|
||||
'source_path' => 'help/deleted-article.md', 'title' => 'Старая', 'topics' => '',
|
||||
'chunk_index' => 0, 'content' => 'мусор', 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
|
||||
|
||||
expect(DB::table('knowledge_chunks')->where('source_path', 'help/deleted-article.md')->count())->toBe(0)
|
||||
->and(DB::table('knowledge_chunks')->where('title', 'Что такое проект')->count())->toBeGreaterThan(0)
|
||||
->and(DB::table('knowledge_chunks')->where('title', 'Тарифы и списания')->count())->toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Help\HelpArticleParser;
|
||||
|
||||
it('каждый tour из статей resources/help существует в каталоге экскурсий', function () {
|
||||
$catalog = (string) file_get_contents(resource_path('js/tours/catalog.ts'));
|
||||
preg_match_all("/name: '([a-z0-9\\-]+)'/", $catalog, $m);
|
||||
$known = $m[1];
|
||||
expect($known)->not->toBeEmpty();
|
||||
|
||||
$parser = new HelpArticleParser;
|
||||
foreach (glob(resource_path('help').'/*.md') ?: [] as $file) {
|
||||
$article = $parser->parse('help/'.basename($file), (string) file_get_contents($file));
|
||||
if ($article->tour !== null) {
|
||||
expect($known)->toContain($article->tour);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Bot\ProcessJivoMessageJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const JIVO_SECRET = 'test-secret-0123456789abcdef0123456789abcdef';
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.jivo_bot.webhook_secret', JIVO_SECRET);
|
||||
});
|
||||
|
||||
function jivoPayload(string $text = 'что такое проект?'): array
|
||||
{
|
||||
return [
|
||||
'event' => 'CLIENT_MESSAGE',
|
||||
'id' => 'evt-1',
|
||||
'chat_id' => 'chat-1',
|
||||
'client_id' => 'client-1',
|
||||
'message' => ['type' => 'TEXT', 'text' => $text, 'timestamp' => 1780000000],
|
||||
];
|
||||
}
|
||||
|
||||
it('валидный секрет + CLIENT_MESSAGE → 200 и джоба в очереди bot', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, jivoPayload())->assertOk();
|
||||
|
||||
Queue::assertPushedOn('bot', ProcessJivoMessageJob::class, function (ProcessJivoMessageJob $job) {
|
||||
return $job->chatId === 'chat-1' && $job->text === 'что такое проект?';
|
||||
});
|
||||
});
|
||||
|
||||
it('неверный секрет → 404 без джобы', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->postJson('/api/webhook/jivo/wrong-secret', jivoPayload())->assertNotFound();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('секрет не настроен (пустой конфиг) → 404 даже с пустым секретом в URL', function () {
|
||||
config()->set('services.jivo_bot.webhook_secret', '');
|
||||
Queue::fake();
|
||||
|
||||
$this->postJson('/api/webhook/jivo/anything', jivoPayload())->assertNotFound();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('не-CLIENT_MESSAGE (служебное событие) → 200 без джобы', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, ['event' => 'AGENT_JOINED', 'chat_id' => 'c'])
|
||||
->assertOk();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('CLIENT_MESSAGE без текста → 200 без джобы', function () {
|
||||
Queue::fake();
|
||||
|
||||
$payload = jivoPayload();
|
||||
$payload['message']['text'] = '';
|
||||
|
||||
$this->postJson('/api/webhook/jivo/'.JIVO_SECRET, $payload)->assertOk();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('knowledge_chunks существует и ищется полнотекстово по-русски', function () {
|
||||
DB::table('knowledge_chunks')->insert([
|
||||
'source_path' => 'help/project.md',
|
||||
'title' => 'Что такое проект',
|
||||
'tour' => 'create-project',
|
||||
'topics' => 'заявка на лиды, создать проект, источник',
|
||||
'chunk_index' => 0,
|
||||
'content' => 'Проект — это заявка на поток лидов с выбранного источника.',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$found = DB::select(
|
||||
"SELECT id, title FROM knowledge_chunks
|
||||
WHERE search_tsv @@ websearch_to_tsquery('russian', ?)",
|
||||
['что такое проект']
|
||||
);
|
||||
|
||||
expect($found)->toHaveCount(1)
|
||||
->and($found[0]->title)->toBe('Что такое проект');
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\KnowledgeChunk;
|
||||
use App\Services\Bot\KnowledgeSearch;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project',
|
||||
'topics' => 'создать проект, заявка на лиды', 'chunk_index' => 0,
|
||||
'content' => 'Проект — это заявка на поток клиентов с выбранного источника.',
|
||||
]);
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => 'top-up-balance',
|
||||
'topics' => 'пополнить, закинуть деньги, оплата', 'chunk_index' => 0,
|
||||
'content' => 'Пополнить баланс: раздел Биллинг, кнопка Пополнить.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('находит релевантный чанк и ранжирует его первым', function () {
|
||||
$hits = app(KnowledgeSearch::class)->search('а что такое проект?', 3);
|
||||
|
||||
expect($hits)->not->toBeEmpty()
|
||||
->and($hits[0]->title)->toBe('Что такое проект');
|
||||
});
|
||||
|
||||
it('находит по синонимам из topics («закинуть деньги»)', function () {
|
||||
$hits = app(KnowledgeSearch::class)->search('как закинуть деньги', 3);
|
||||
|
||||
expect($hits)->not->toBeEmpty()
|
||||
->and($hits[0]->title)->toBe('Как пополнить баланс');
|
||||
});
|
||||
|
||||
it('на вопрос не по теме возвращает пусто', function () {
|
||||
expect(app(KnowledgeSearch::class)->search('какая погода в москве', 3))->toBeEmpty();
|
||||
});
|
||||
|
||||
it('не падает на спецсимволах в вопросе', function () {
|
||||
expect(app(KnowledgeSearch::class)->search('проект & | ! ( )', 3))->toBeArray();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Bot\ProcessJivoMessageJob;
|
||||
use App\Models\BotDialog;
|
||||
use App\Models\KnowledgeChunk;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/p/t');
|
||||
config()->set('services.yandexgpt', [
|
||||
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
|
||||
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
|
||||
'timeout_seconds' => 8,
|
||||
]);
|
||||
KnowledgeChunk::create([
|
||||
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => null,
|
||||
'topics' => 'создать проект', 'chunk_index' => 0,
|
||||
'content' => 'Проект — это заявка на поток клиентов.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('happy path: ответ уходит в Jivo, журнал пишет in+out с latency', function () {
|
||||
Http::fake([
|
||||
'llm.api.cloud.yandex.net/*' => Http::response([
|
||||
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
|
||||
]),
|
||||
'bot.jivosite.com/*' => Http::response(['ok' => true]),
|
||||
]);
|
||||
|
||||
(new ProcessJivoMessageJob('chat-1', 'client-1', 'что такое проект?'))->handle();
|
||||
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), 'bot.jivosite.com') && $r['event'] === 'BOT_MESSAGE');
|
||||
|
||||
$out = BotDialog::where('direction', 'out')->firstOrFail();
|
||||
expect(BotDialog::where('direction', 'in')->count())->toBe(1)
|
||||
->and($out->latency_ms)->toBeGreaterThanOrEqual(0)
|
||||
->and($out->escalated)->toBeFalse()
|
||||
->and($out->matched_chunks)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('эскалация: BOT_MESSAGE-прощание + INVITE_AGENT, журнал escalated=true', function () {
|
||||
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
|
||||
|
||||
(new ProcessJivoMessageJob('chat-2', 'client-2', 'какой у меня баланс?'))->handle();
|
||||
|
||||
Http::assertSent(fn ($r) => ($r['event'] ?? '') === 'INVITE_AGENT');
|
||||
expect(BotDialog::where('direction', 'out')->firstOrFail()->escalated)->toBeTrue();
|
||||
});
|
||||
|
||||
it('джоба объявлена с queue=bot и timeout ≤ 12 сек', function () {
|
||||
$job = new ProcessJivoMessageJob('c', 'c', 'q');
|
||||
|
||||
expect($job->queue)->toBe('bot')
|
||||
->and($job->timeout)->toBeLessThanOrEqual(12);
|
||||
});
|
||||
@@ -51,7 +51,6 @@ it('401 без авторизации', function () {
|
||||
|
||||
it('возвращает структуру summary с range по умолчанию 7d', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'limits' => ['max_projects' => 10],
|
||||
'balance_rub' => '14250.00',
|
||||
'balance_leads' => 285,
|
||||
]);
|
||||
@@ -64,7 +63,7 @@ it('возвращает структуру summary с range по умолчан
|
||||
'range',
|
||||
'leads_received' => ['value', 'delta_pct', 'delta_dir'],
|
||||
'conversion' => ['value', 'delta_pp', 'delta_dir'],
|
||||
'active_projects' => ['active', 'limit'],
|
||||
'active_projects' => ['active'],
|
||||
'balance' => ['amount_rub', 'runway_days', 'runway_leads'],
|
||||
'activity' => ['points', 'labels', 'max'],
|
||||
'funnel',
|
||||
@@ -103,8 +102,8 @@ it('conversion = доля статуса won в окне', function () {
|
||||
->assertJsonPath('conversion.value', 25);
|
||||
});
|
||||
|
||||
it('active_projects считает is_active=true + limit из limits', function () {
|
||||
$tenant = Tenant::factory()->create(['limits' => ['max_projects' => 10]]);
|
||||
it('active_projects считает только is_active=true (лимита по числу проектов нет)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
actingForTenant($tenant);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
@@ -112,7 +111,7 @@ it('active_projects считает is_active=true + limit из limits', function
|
||||
$this->getJson('/api/dashboard/summary')
|
||||
->assertOk()
|
||||
->assertJsonPath('active_projects.active', 2)
|
||||
->assertJsonPath('active_projects.limit', 10);
|
||||
->assertJsonMissingPath('active_projects.limit');
|
||||
});
|
||||
|
||||
it('funnel группирует живые сделки по статусу', function () {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Guard против повторения инцидента входа 26.06.2026 (см. db/CHANGELOG_schema.md v8.57).
|
||||
//
|
||||
// На Yandex Managed PG (PgBouncer transaction pooling) GUC app.current_tenant_id на
|
||||
// пуло-соединении бывает пуст ('') или не задан. Прямое приведение
|
||||
// current_setting('app.current_tenant_id'[, true])::bigint
|
||||
// падает: '' → 22P02 (invalid bigint), не задан → 42704 (unrecognized parameter).
|
||||
// Это роняло вход (резолв users до tenant-контекста). Канон — всегда:
|
||||
// NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
|
||||
// Любое прямое приведение в db/ снова сломает вход. Тест статический (без БД).
|
||||
|
||||
it('в schema.sql нет небезопасного current_setting(app.current_tenant_id)::bigint (только через NULLIF)', function () {
|
||||
// Канон для пересборки БД — db/schema.sql (psql -f). Он ОБЯЗАН быть чистым.
|
||||
// Старые миграции — неизменяемая история; их небезопасные политики пересоздаёт
|
||||
// hardening-миграция 2026_06_26_153000 (итог migrate:fresh безопасен), поэтому
|
||||
// их здесь не сканируем — иначе ложные срабатывания на superseded-истории.
|
||||
//
|
||||
// Прямое приведение current_setting(...)::bigint без обёртки NULLIF.
|
||||
// Безопасная форма NULLIF(current_setting(...), '')::bigint этому НЕ соответствует:
|
||||
// там после current_setting(...) идёт ", ''", а не "::bigint".
|
||||
$unsafe = "/current_setting\\(\\s*'app\\.current_tenant_id'[^)]*\\)\\s*::\\s*bigint/i";
|
||||
|
||||
$offenders = [];
|
||||
$lines = file(base_path('..').'/db/schema.sql', FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($lines as $i => $line) {
|
||||
if (str_starts_with(ltrim($line), '--')) {
|
||||
continue; // строки-комментарии (документация) — не код политики
|
||||
}
|
||||
if (preg_match($unsafe, $line)) {
|
||||
$offenders[] = 'schema.sql:'.($i + 1).' → '.trim($line);
|
||||
}
|
||||
}
|
||||
|
||||
expect($offenders)->toBe(
|
||||
[],
|
||||
'Небезопасное приведение GUC к bigint (без NULLIF) в schema.sql вернёт инцидент входа на Managed PG/PgBouncer:'
|
||||
.PHP_EOL.implode(PHP_EOL, $offenders)
|
||||
);
|
||||
});
|
||||
|
||||
it('5 bootstrap-таблиц в schema.sql сохраняют ветку "NULLIF(...) IS NULL OR ..."', function () {
|
||||
$schema = file_get_contents(base_path('..').'/db/schema.sql');
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
foreach (['users', 'auth_log', 'email_verifications', 'user_recovery_codes', 'user_sessions'] as $table) {
|
||||
// В пределах одного CREATE POLICY ... ON <table> ... ; должно быть условие
|
||||
// NULLIF(current_setting('app.current_tenant_id', true), '') IS NULL.
|
||||
$pattern = '/POLICY tenant_isolation ON '.preg_quote($table, '/')
|
||||
."\\b[^;]*?NULLIF\\(current_setting\\('app\\.current_tenant_id', true\\), ''\\)\\s*IS NULL/s";
|
||||
expect((bool) preg_match($pattern, $schema))->toBeTrue(
|
||||
"Таблица {$table} должна иметь bootstrap-ветку «NULLIF(...) IS NULL OR ...» "
|
||||
.'(резолв до tenant-контекста на auth-роутах). Иначе вход/2FA/подтверждение почты сломаются.'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -59,7 +59,7 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS policies', function () {
|
||||
it('schema.sql v8.58 has correct metrics — 76 base tables, 130 indexes, 44 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик.
|
||||
@@ -76,8 +76,9 @@ it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS
|
||||
// project_routing_snapshots, tenant_requisites, support_requests и др.).
|
||||
// v8.54 (Эпик 4 online-defer): +1 таблица supplier_deferred_sync (SaaS-level, PK неявный, +0 явных индексов).
|
||||
// v8.55 (Эпик 5 отчёт заливки): +1 таблица supplier_sync_runs + 1 индекс idx_supplier_sync_runs_created.
|
||||
// Статический парс db/schema.sql после v8.54/v8.55: 74 base tables, 128 индексов, 44 RLS-политики.
|
||||
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф (заявляет 79 таблиц/124 индекса) —
|
||||
// v8.58 (ИИ-бот Jivo): +2 таблицы knowledge_chunks (база знаний, GIN search_tsv) и bot_dialogs
|
||||
// (журнал диалогов) + 2 индекса. Статический парс: 76 base tables, 130 индексов, 44 RLS-политики.
|
||||
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф —
|
||||
// это отдельный canon-sync, не предмет этого теста; тест сверяет фактический парс ФАЙЛА.
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
@@ -88,10 +89,10 @@ it('schema.sql v8.55 has correct metrics — 74 base tables, 128 indexes, 44 RLS
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(74);
|
||||
expect($baseTables)->toBe(76);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(128); // v8.55 static parse
|
||||
expect($createIndexes)->toBe(130); // v8.58 static parse
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(44); // v8.52 static parse
|
||||
|
||||
@@ -122,18 +122,24 @@ it('rejects sms project without sms_senders', function () {
|
||||
$response->assertJsonValidationErrors(['sms_senders']);
|
||||
});
|
||||
|
||||
it('rejects when tenant exceeds max_projects limit', function () {
|
||||
$tenant = Tenant::factory()->withRequisites()->create(['limits' => ['max_projects' => 1]]);
|
||||
it('does not cap the number of projects — limit is only by balance, not project count', function () {
|
||||
// Правило продукта: ограничение только по балансу/заказанным лидам, НЕ по числу проектов.
|
||||
// Даже явно заданный max_projects=1 не должен блокировать создание второго проекта.
|
||||
// Большой баланс — чтобы изолировать тест от балансового префлайта (он отдельный, 409).
|
||||
$tenant = Tenant::factory()->withRequisites()->create([
|
||||
'limits' => ['max_projects' => 1],
|
||||
'balance_rub' => '10000000.00',
|
||||
]);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true, 'daily_limit_target' => 1]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'second', 'signal_type' => 'site', 'signal_identifier' => 'second.ru',
|
||||
'daily_limit_target' => 10, 'regions' => [],
|
||||
'daily_limit_target' => 1, 'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertCreated();
|
||||
});
|
||||
|
||||
it('forces tenant_id from auth user (not from payload)', function () {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AdminDashboardView from '../../resources/js/views/admin/AdminDashboardView.vue';
|
||||
|
||||
// Мокаем клиент дашборда: 3 GET-эндпоинта возвращают фикстуры.
|
||||
vi.mock('../../resources/js/api/adminDashboard', () => ({
|
||||
getDashboardSummary: vi.fn().mockResolvedValue({
|
||||
period: '7d',
|
||||
finance: {
|
||||
topups_rub: '320000',
|
||||
charges_rub: '180000',
|
||||
active_clients: 5,
|
||||
new_clients: 2,
|
||||
negative_balance_count: 1,
|
||||
light: 'red',
|
||||
},
|
||||
health: {
|
||||
light: 'green',
|
||||
open_incidents: 0,
|
||||
job_errors_24h: 0,
|
||||
failed_jobs_24h: 0,
|
||||
last_sync_status: 'success',
|
||||
last_sync_at: null,
|
||||
},
|
||||
leads: { light: 'green', delivered_today: 71, received_today: 80, stuck: 0, unrouted: 0 },
|
||||
supply: { light: 'red', demand: 250, formula: 160, ordered: 175, mismatches: 1, total_orders: 405, total_limit: 5031, snapshot_date: '2026-06-28' },
|
||||
}),
|
||||
getDashboardFinance: vi.fn().mockResolvedValue({
|
||||
period: '7d',
|
||||
kpi: { topups_rub: '320000', charges_rub: '180000', net_inflow_rub: '140000', negative_balance_count: 1 },
|
||||
attention: [{ id: 9, subdomain: 'romashka', organization_name: 'ООО Ромашка', balance_rub: '-4200', state: 'negative' }],
|
||||
top_by_turnover: [{ id: 2, organization_name: 'lkomega', topped_rub: '200000' }],
|
||||
}),
|
||||
getDashboardHealth: vi.fn().mockResolvedValue({
|
||||
overall_light: 'green',
|
||||
subsystems: [
|
||||
{ key: 'queues', light: 'green', detail: '0 упавших за сутки' },
|
||||
{ key: 'incidents', light: 'green', detail: '0 открытых' },
|
||||
],
|
||||
}),
|
||||
getDashboardLeads: vi.fn().mockResolvedValue({
|
||||
light: 'green',
|
||||
kpi: { delivered_today: 71, received_today: 80, stuck: 0, unrouted: 0 },
|
||||
}),
|
||||
getDashboardSupply: vi.fn().mockResolvedValue({
|
||||
snapshot_date: '2026-06-28',
|
||||
light: 'red',
|
||||
totals: { demand: 250, formula: 160, ordered: 175, mismatches: 1 },
|
||||
total_orders: 405,
|
||||
total_limit: 5031,
|
||||
groups: [{ signal_type: 'site', identifier: 'okna.ru', demand: 150, formula: 100, ordered: 100, in_sync: true }],
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AdminDashboardView.vue', () => {
|
||||
const factory = async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/admin/dashboard', name: 'admin-dashboard', component: AdminDashboardView },
|
||||
{ path: '/admin/tenants/:code', name: 'admin-tenant-detail', component: { template: '<div />' } },
|
||||
],
|
||||
});
|
||||
await router.push('/admin/dashboard');
|
||||
await router.isReady();
|
||||
const wrapper = mount(AdminDashboardView, {
|
||||
global: { plugins: [createVuetify(), router] },
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
return { wrapper, router };
|
||||
};
|
||||
|
||||
it('монтируется и содержит заголовок «Командный центр»', async () => {
|
||||
const { wrapper } = await factory();
|
||||
expect(wrapper.find('h1').text()).toBe('Командный центр');
|
||||
});
|
||||
|
||||
it('рендерит 4 плитки: Финансы / Здоровье / Лиды / Заказ у поставщика', async () => {
|
||||
const { wrapper } = await factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Финансы');
|
||||
expect(text).toContain('Здоровье портала');
|
||||
expect(text).toContain('Лиды');
|
||||
expect(text).toContain('Заказ у поставщика');
|
||||
});
|
||||
|
||||
it('плитки Лиды и Заказ показывают живые числа', async () => {
|
||||
const { wrapper } = await factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Доставлено сегодня');
|
||||
expect(text).toContain('71');
|
||||
expect(text).toContain('1 рассинхрон'); // светофор Заказа (mismatches=1)
|
||||
});
|
||||
|
||||
it('клик по плитке Заказ показывает таблицу групп', async () => {
|
||||
const { wrapper } = await factory();
|
||||
await wrapper.find('[data-testid="tile-supply"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="drill-supply"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('okna.ru');
|
||||
expect(wrapper.text()).toContain('По группам');
|
||||
});
|
||||
|
||||
it('Финансы и Здоровье показывают живые числа из API', async () => {
|
||||
const { wrapper } = await factory();
|
||||
const text = wrapper.text();
|
||||
expect(text).toMatch(/320\s+000\s*₽/); // пополнения
|
||||
expect(text).toContain('1 в минусе'); // светофор Финансов (red)
|
||||
expect(text).toContain('success'); // статус синхрона
|
||||
});
|
||||
|
||||
it('по умолчанию открыт drill Финансов с KPI «Чистый приток»', async () => {
|
||||
const { wrapper } = await factory();
|
||||
expect(wrapper.find('[data-testid="drill-fin"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toMatch(/140\s+000\s*₽/); // net_inflow
|
||||
expect(wrapper.text()).toContain('ООО Ромашка'); // строка «внимание»
|
||||
});
|
||||
|
||||
it('клик по плитке Здоровье переключает drill на подсистемы', async () => {
|
||||
const { wrapper } = await factory();
|
||||
await wrapper.find('[data-testid="tile-health"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="drill-health"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="drill-fin"]').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('Очереди / джобы');
|
||||
});
|
||||
|
||||
it('клик по строке «внимание» уводит на карточку тенанта (Уровень 3)', async () => {
|
||||
const { wrapper, router } = await factory();
|
||||
const push = vi.spyOn(router, 'push');
|
||||
await wrapper.find('[data-testid="drill-fin"] tbody tr.clk').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(push).toHaveBeenCalledWith({ name: 'admin-tenant-detail', params: { code: 'romashka' } });
|
||||
});
|
||||
|
||||
it('смена периода перезагружает данные (вызов summary дважды)', async () => {
|
||||
const { wrapper } = await factory();
|
||||
const api = await import('../../resources/js/api/adminDashboard');
|
||||
expect(api.getDashboardSummary).toHaveBeenCalledTimes(1);
|
||||
await wrapper.find('[data-testid="period-30d"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(api.getDashboardSummary).toHaveBeenCalledTimes(2);
|
||||
expect(api.getDashboardSummary).toHaveBeenLastCalledWith('30d');
|
||||
});
|
||||
|
||||
it('API reject → fetch-error-alert виден', async () => {
|
||||
const api = await import('../../resources/js/api/adminDashboard');
|
||||
vi.mocked(api.getDashboardSummary).mockRejectedValueOnce(new Error('Network'));
|
||||
const { wrapper } = await factory();
|
||||
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
describe('AdminSupplierIntegrationView — тумблер разблокировки смены источника', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url.endsWith('/source-edit-flag')) {
|
||||
return Promise.resolve({ data: { enabled: true } });
|
||||
}
|
||||
if (url.endsWith('/export-mode')) {
|
||||
return Promise.resolve({ data: { mode: 'batch' } });
|
||||
}
|
||||
if (url.endsWith('/manual-queue')) {
|
||||
return Promise.resolve({ data: { queue: [] } });
|
||||
}
|
||||
return Promise.resolve({ data: { health: null, history: [] } });
|
||||
});
|
||||
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { enabled: false } });
|
||||
});
|
||||
|
||||
it('GETs the flag on mount and renders the toggle card with current label', async () => {
|
||||
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration/source-edit-flag');
|
||||
const card = wrapper.find('[data-testid="source-edit-flag-card"]');
|
||||
expect(card.exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('Разблокировка смены источника');
|
||||
// флаг enabled=true с бэка → подпись «Включена»
|
||||
expect(wrapper.text()).toContain('Включена');
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummar
|
||||
range: '7d',
|
||||
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
|
||||
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
|
||||
active_projects: { active: 8, limit: 10 },
|
||||
active_projects: { active: 8 },
|
||||
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
|
||||
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
|
||||
funnel: { new: 18, won: 45 },
|
||||
@@ -112,7 +112,7 @@ describe('DashboardView — косяк 07: онбординг новичка', (
|
||||
it('показывает онбординг новичку без проектов и лидов', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({
|
||||
active_projects: { active: 0, limit: 0 },
|
||||
active_projects: { active: 0 },
|
||||
leads_received: { value: 0, delta_pct: 0, delta_dir: 'neutral' },
|
||||
}),
|
||||
);
|
||||
@@ -126,7 +126,7 @@ describe('DashboardView — косяк 07: онбординг новичка', (
|
||||
it('скрывает онбординг после «скрыть» и помнит это в localStorage', async () => {
|
||||
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
|
||||
makeSummary({
|
||||
active_projects: { active: 0, limit: 0 },
|
||||
active_projects: { active: 0 },
|
||||
leads_received: { value: 0, delta_pct: 0, delta_dir: 'neutral' },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../resources/js/api/client', () => ({
|
||||
|
||||
import { apiClient } from '../../resources/js/api/client';
|
||||
import EditProjectDialog from '../../resources/js/views/projects/EditProjectDialog.vue';
|
||||
import type { Project } from '../../resources/js/stores/projectsStore';
|
||||
|
||||
const sampleProject = {
|
||||
id: 1,
|
||||
@@ -31,7 +32,7 @@ const sampleProject = {
|
||||
|
||||
// VDialog в JSDOM не рендерит через teleport — стаб делает <slot/> доступным
|
||||
// для wrapper.text() / find(). Паттерн из NewProjectDialog.spec.ts.
|
||||
const factory = (props: { modelValue: boolean; project: typeof sampleProject }) =>
|
||||
const factory = (props: { modelValue: boolean; project: Project }) =>
|
||||
mount(EditProjectDialog, {
|
||||
props,
|
||||
global: {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import GuidedTour from '../../resources/js/components/layout/GuidedTour.vue';
|
||||
import type { TourStep } from '../../resources/js/tours/catalog';
|
||||
|
||||
const steps: TourStep[] = [
|
||||
{ route: '/projects', target: '[data-tour="a"]', title: 'Шаг 1', text: 'т1' },
|
||||
{ route: '/projects', target: '[data-tour="b"]', title: 'Шаг 2', text: 'т2' },
|
||||
];
|
||||
|
||||
function mountTour() {
|
||||
return mount(GuidedTour, {
|
||||
props: { steps, active: true },
|
||||
global: { stubs: { 'v-btn': { template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('GuidedTour', () => {
|
||||
it('показывает первый шаг и счётчик', () => {
|
||||
const w = mountTour();
|
||||
expect(w.text()).toContain('Шаг 1');
|
||||
expect(w.text()).toContain('1 из 2');
|
||||
});
|
||||
|
||||
it('Далее ведёт по шагам, на последнем — Готово и finish', async () => {
|
||||
const w = mountTour();
|
||||
await w.find('[data-testid="tour-next"]').trigger('click');
|
||||
expect(w.text()).toContain('Шаг 2');
|
||||
await w.find('[data-testid="tour-next"]').trigger('click');
|
||||
expect(w.emitted('finish')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Пропустить завершает тур сразу', async () => {
|
||||
const w = mountTour();
|
||||
await w.find('[data-testid="tour-skip"]').trigger('click');
|
||||
expect(w.emitted('finish')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('цель не найдена → карточка по центру (targetRect null), без падения', () => {
|
||||
const w = mountTour();
|
||||
expect(w.find('[data-testid="guided-tour"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('ретрай измерения: цель появляется позже — подсветка находит её', async () => {
|
||||
vi.useFakeTimers();
|
||||
const w = mountTour();
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-tour', 'a');
|
||||
document.body.appendChild(el);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((w.vm as any).targetRect).not.toBeNull();
|
||||
el.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,18 @@ describe('NewProjectDialog', () => {
|
||||
expect(text).toContain('СМС');
|
||||
});
|
||||
|
||||
it('shows hint «Как увеличить количество сделок» with explanatory tooltip', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Как увеличить количество сделок');
|
||||
const tips = wrapper.findAllComponents({ name: 'VTooltip' });
|
||||
const boost = tips.find((t) =>
|
||||
String(t.props('text')).includes('распределяется на нескольких поставщиков'),
|
||||
);
|
||||
expect(boost).toBeTruthy();
|
||||
expect(String(boost?.props('text'))).toContain('увеличьте лимит');
|
||||
});
|
||||
|
||||
it('switching to SMS tab shows sms_senders field', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TOURS, findTour, type TourScenario } from '../../resources/js/tours/catalog';
|
||||
|
||||
describe('каталог экскурсий', () => {
|
||||
it('содержит 5 стартовых сценариев с уникальными именами', () => {
|
||||
const names = TOURS.map((t: TourScenario) => t.name);
|
||||
expect(names).toEqual([...new Set(names)]);
|
||||
for (const required of ['create-project', 'top-up-balance', 'tariffs', 'change-source', 'notifications']) {
|
||||
expect(names).toContain(required);
|
||||
}
|
||||
});
|
||||
|
||||
it('каждый шаг имеет route, target, title и text', () => {
|
||||
for (const tour of TOURS) {
|
||||
expect(tour.steps.length).toBeGreaterThan(0);
|
||||
for (const s of tour.steps) {
|
||||
expect(s.route.startsWith('/')).toBe(true);
|
||||
expect(s.target).toBeTruthy();
|
||||
expect(s.title).toBeTruthy();
|
||||
expect(s.text).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('findTour находит по имени и отдаёт null на мусор', () => {
|
||||
expect(findTour('create-project')?.name).toBe('create-project');
|
||||
expect(findTour('no-such-tour')).toBeNull();
|
||||
expect(findTour('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useTourLauncher } from '../../resources/js/composables/useTourLauncher';
|
||||
|
||||
function makeRouterMocks(query: Record<string, string>) {
|
||||
const route = ref({ query, fullPath: '/x' });
|
||||
const router = { push: vi.fn().mockResolvedValue(undefined), replace: vi.fn().mockResolvedValue(undefined) };
|
||||
return { route, router };
|
||||
}
|
||||
|
||||
describe('useTourLauncher', () => {
|
||||
it('валидный ?tour= → активирует сценарий и ведёт на роут первого шага', async () => {
|
||||
const { route, router } = makeRouterMocks({ tour: 'create-project' });
|
||||
const l = useTourLauncher(route as never, router as never);
|
||||
await l.checkQuery();
|
||||
expect(l.activeTour.value?.name).toBe('create-project');
|
||||
expect(router.push).toHaveBeenCalledWith({ path: '/projects', query: {} });
|
||||
});
|
||||
|
||||
it('мусорный ?tour= → игнор без падения, query чистится', async () => {
|
||||
const { route, router } = makeRouterMocks({ tour: 'no-such' });
|
||||
const l = useTourLauncher(route as never, router as never);
|
||||
await l.checkQuery();
|
||||
expect(l.activeTour.value).toBeNull();
|
||||
expect(router.replace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('без ?tour= — ничего не делает', async () => {
|
||||
const { route, router } = makeRouterMocks({});
|
||||
const l = useTourLauncher(route as never, router as never);
|
||||
await l.checkQuery();
|
||||
expect(l.activeTour.value).toBeNull();
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finishTour гасит активный тур', async () => {
|
||||
const { route, router } = makeRouterMocks({ tour: 'tariffs' });
|
||||
const l = useTourLauncher(route as never, router as never);
|
||||
await l.checkQuery();
|
||||
l.finishTour();
|
||||
expect(l.activeTour.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { installMenuRepositionFix } from '../../resources/js/utils/menuRepositionFix';
|
||||
|
||||
// Ручной requestAnimationFrame: кадры прогоняем детерминированно.
|
||||
let rafQueue: FrameRequestCallback[] = [];
|
||||
function flushFrames(n: number): void {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const batch = rafQueue;
|
||||
rafQueue = [];
|
||||
batch.forEach((cb) => cb(0));
|
||||
}
|
||||
}
|
||||
|
||||
// Дать MutationObserver (микротаска jsdom) сработать.
|
||||
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function makeMenu(): HTMLElement {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'v-overlay v-menu';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'v-overlay__content';
|
||||
menu.appendChild(content);
|
||||
return menu;
|
||||
}
|
||||
|
||||
let teardown: (() => void) | undefined;
|
||||
let rectWidth = 200;
|
||||
let rectLeft = 100;
|
||||
let origRect: typeof HTMLElement.prototype.getBoundingClientRect;
|
||||
let resizeSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
rectWidth = 200;
|
||||
rectLeft = 100;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafQueue.push(cb);
|
||||
return rafQueue.length;
|
||||
});
|
||||
origRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
HTMLElement.prototype.getBoundingClientRect = function (): DOMRect {
|
||||
return {
|
||||
width: rectWidth,
|
||||
height: 10,
|
||||
left: rectLeft,
|
||||
top: 0,
|
||||
right: rectLeft + rectWidth,
|
||||
bottom: 10,
|
||||
x: rectLeft,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
};
|
||||
resizeSpy = vi.fn();
|
||||
window.addEventListener('resize', resizeSpy as unknown as EventListener);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardown?.();
|
||||
teardown = undefined;
|
||||
HTMLElement.prototype.getBoundingClientRect = origRect;
|
||||
window.removeEventListener('resize', resizeSpy as unknown as EventListener);
|
||||
vi.unstubAllGlobals();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('installMenuRepositionFix', () => {
|
||||
it('при появлении меню стабилизирует позицию и шлёт один resize', async () => {
|
||||
teardown = installMenuRepositionFix();
|
||||
document.body.appendChild(makeMenu());
|
||||
await tick();
|
||||
flushFrames(6);
|
||||
expect(resizeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('на посторонний узел не реагирует', async () => {
|
||||
teardown = installMenuRepositionFix();
|
||||
const other = document.createElement('div');
|
||||
other.className = 'some-card';
|
||||
document.body.appendChild(other);
|
||||
await tick();
|
||||
flushFrames(6);
|
||||
expect(resizeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('идемпотентна: двойная установка не даёт двойной resize', async () => {
|
||||
teardown = installMenuRepositionFix();
|
||||
installMenuRepositionFix(); // второй вызов — noop
|
||||
document.body.appendChild(makeMenu());
|
||||
await tick();
|
||||
flushFrames(6);
|
||||
expect(resizeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('предохранитель: если геометрия не устаканилась — не виснет и не шлёт resize', async () => {
|
||||
rectWidth = 0; // контент «нулевой» → условие стабилизации не выполняется
|
||||
teardown = installMenuRepositionFix();
|
||||
document.body.appendChild(makeMenu());
|
||||
await tick();
|
||||
flushFrames(120); // больше предохранителя (90 кадров)
|
||||
expect(resizeSpy).not.toHaveBeenCalled();
|
||||
expect(rafQueue.length).toBe(0); // цикл остановился, не зациклился
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { repositionMenuAfterOpen } from '../../resources/js/utils/menuRepositionFix';
|
||||
|
||||
/**
|
||||
* Unit-тесты воркэраунда Vuetify location-strategy (см. menuRepositionFix.ts).
|
||||
* Реальный баг — гонка позиционирования в браузере под prefers-reduced-motion —
|
||||
* в jsdom не воспроизводится (нет layout); он покрыт Playwright-пробой. Здесь
|
||||
* проверяется контракт утилиты: при стабилизации overlay-меню шлётся один resize.
|
||||
*/
|
||||
function makeStableMenu(left: number): HTMLElement {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'v-overlay v-menu';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'v-overlay__content';
|
||||
content.getBoundingClientRect = () =>
|
||||
({
|
||||
width: 400,
|
||||
height: 300,
|
||||
left,
|
||||
top: 50,
|
||||
right: left + 400,
|
||||
bottom: 350,
|
||||
x: left,
|
||||
y: 50,
|
||||
toJSON() {},
|
||||
}) as DOMRect;
|
||||
overlay.appendChild(content);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('repositionMenuAfterOpen', () => {
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.v-overlay').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
it('does nothing when menu is closing (open=false)', async () => {
|
||||
const spy = vi.fn();
|
||||
window.addEventListener('resize', spy);
|
||||
repositionMenuAfterOpen(false);
|
||||
await wait(200);
|
||||
window.removeEventListener('resize', spy);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches a single resize once the overlay content is geometrically stable', async () => {
|
||||
makeStableMenu(120);
|
||||
const spy = vi.fn();
|
||||
window.addEventListener('resize', spy);
|
||||
repositionMenuAfterOpen(true);
|
||||
await wait(400);
|
||||
window.removeEventListener('resize', spy);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch resize or throw when no overlay is present', async () => {
|
||||
const spy = vi.fn();
|
||||
window.addEventListener('resize', spy);
|
||||
expect(() => repositionMenuAfterOpen(true)).not.toThrow();
|
||||
await wait(300);
|
||||
window.removeEventListener('resize', spy);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -109,7 +109,7 @@ describe('router/index.ts', () => {
|
||||
expect(router.currentRoute.value.meta.layout).toBe('error');
|
||||
});
|
||||
|
||||
it('/admin redirects to /admin/tenants', async () => {
|
||||
it('/admin redirects to /admin/dashboard', async () => {
|
||||
const auth = useAuthStore();
|
||||
auth.user = {
|
||||
id: 1,
|
||||
@@ -122,8 +122,8 @@ describe('router/index.ts', () => {
|
||||
} as unknown as ReturnType<typeof useAuthStore>['user'];
|
||||
await router.push('/admin');
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.path).toBe('/admin/tenants');
|
||||
expect(router.currentRoute.value.name).toBe('admin-tenants');
|
||||
expect(router.currentRoute.value.path).toBe('/admin/dashboard');
|
||||
expect(router.currentRoute.value.name).toBe('admin-dashboard');
|
||||
});
|
||||
|
||||
it('/reset/:token resolves with token param exposed', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesAdminPdo;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
@@ -23,6 +24,10 @@ pest()->extend(TestCase::class)
|
||||
// ->use(RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
|
||||
// admin-db middleware swaps default→pgsql_admin; share PDO для cross-connection
|
||||
// visibility в admin-тестах (любой /api/admin/* эндпоинт). Глобально по Feature.
|
||||
uses(SharesAdminPdo::class)->in('Feature');
|
||||
|
||||
pest()->extend(TestCase::class)->in('Browser');
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Help\HelpArticleParser;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('парсит frontmatter и режет тело на чанки по абзацам', function () {
|
||||
$md = <<<'MD'
|
||||
---
|
||||
title: Что такое проект
|
||||
tour: create-project
|
||||
topics: создать проект, заявка на лиды
|
||||
---
|
||||
|
||||
Первый абзац про проект.
|
||||
|
||||
Второй абзац про создание.
|
||||
MD;
|
||||
|
||||
$article = (new HelpArticleParser)->parse('help/x.md', $md);
|
||||
|
||||
expect($article->title)->toBe('Что такое проект')
|
||||
->and($article->tour)->toBe('create-project')
|
||||
->and($article->topics)->toBe('создать проект, заявка на лиды')
|
||||
->and($article->chunks)->toHaveCount(1)
|
||||
->and($article->chunks[0])->toContain('Первый абзац');
|
||||
});
|
||||
|
||||
it('без frontmatter кидает понятную ошибку', function () {
|
||||
expect(fn () => (new HelpArticleParser)->parse('help/bad.md', 'просто текст'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('длинное тело режет на несколько чанков ~1200 символов по границам абзацев', function () {
|
||||
$body = implode("\n\n", array_fill(0, 10, str_repeat('а', 300)));
|
||||
$md = "---\ntitle: Т\ntopics: т\n---\n\n".$body;
|
||||
|
||||
$article = (new HelpArticleParser)->parse('help/long.md', $md);
|
||||
|
||||
expect(count($article->chunks))->toBeGreaterThan(1);
|
||||
foreach ($article->chunks as $chunk) {
|
||||
expect(mb_strlen($chunk))->toBeLessThanOrEqual(1500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Bot\JivoBotClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.jivo_bot.outbound_url', 'https://bot.jivosite.com/webhooks/prov-1/tok-1');
|
||||
});
|
||||
|
||||
it('BOT_MESSAGE уходит с chat_id/client_id и текстом', function () {
|
||||
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
|
||||
|
||||
app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'Проект — это…');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://bot.jivosite.com/webhooks/prov-1/tok-1'
|
||||
&& $request['event'] === 'BOT_MESSAGE'
|
||||
&& $request['chat_id'] === 'chat-1'
|
||||
&& $request['client_id'] === 'client-1'
|
||||
&& $request['message']['type'] === 'TEXT'
|
||||
&& $request['message']['text'] === 'Проект — это…';
|
||||
});
|
||||
});
|
||||
|
||||
it('INVITE_AGENT уходит без message', function () {
|
||||
Http::fake(['bot.jivosite.com/*' => Http::response(['ok' => true])]);
|
||||
|
||||
app(JivoBotClient::class)->inviteAgent('chat-1', 'client-1');
|
||||
|
||||
Http::assertSent(fn ($r) => $r['event'] === 'INVITE_AGENT' && $r['chat_id'] === 'chat-1');
|
||||
});
|
||||
|
||||
it('пустой outbound_url (dev/CI) → ничего не шлёт и не падает', function () {
|
||||
config()->set('services.jivo_bot.outbound_url', '');
|
||||
Http::fake();
|
||||
|
||||
app(JivoBotClient::class)->sendMessage('chat-1', 'client-1', 'x');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Bot\YandexGptClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.yandexgpt', [
|
||||
'api_key' => 'test-key', 'folder_id' => 'b1gtest', 'model' => 'yandexgpt-lite/latest',
|
||||
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
|
||||
'timeout_seconds' => 8,
|
||||
]);
|
||||
});
|
||||
|
||||
it('шлёт правильный запрос и возвращает текст ответа', function () {
|
||||
Http::fake([
|
||||
'llm.api.cloud.yandex.net/*' => Http::response([
|
||||
'result' => ['alternatives' => [['message' => ['role' => 'assistant', 'text' => 'Проект — это…']]]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$text = app(YandexGptClient::class)->complete('системный наказ', 'что такое проект?');
|
||||
|
||||
expect($text)->toBe('Проект — это…');
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->hasHeader('Authorization', 'Api-Key test-key')
|
||||
&& $request['modelUri'] === 'gpt://b1gtest/yandexgpt-lite/latest'
|
||||
&& $request['messages'][0]['role'] === 'system'
|
||||
&& $request['messages'][1]['role'] === 'user'
|
||||
&& $request['completionOptions']['temperature'] === 0.2;
|
||||
});
|
||||
});
|
||||
|
||||
it('пустой api_key → null (бот не настроен, не исключение)', function () {
|
||||
config()->set('services.yandexgpt.api_key', '');
|
||||
|
||||
expect(app(YandexGptClient::class)->complete('s', 'u'))->toBeNull();
|
||||
});
|
||||
|
||||
it('ошибка API → null (эскалация решается выше)', function () {
|
||||
Http::fake(['llm.api.cloud.yandex.net/*' => Http::response('err', 500)]);
|
||||
|
||||
expect(app(YandexGptClient::class)->complete('s', 'u'))->toBeNull();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user