Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3561028dd2 | |||
| 4387333118 | |||
| 3d4261cba1 | |||
| ef815c0b8c | |||
| 9b4622da85 | |||
| 23263d18a0 | |||
| 5ba553a0cc | |||
| 48509572b5 | |||
| 3bc4325b78 | |||
| 361d02a256 | |||
| 33ac1a5954 | |||
| 17d93a144b | |||
| aa807c0ed4 | |||
| e52e958484 | |||
| 8cc6511edd | |||
| 02d2163e75 | |||
| 3c8886c97f | |||
| f208fe2f65 | |||
| 98b26f6191 | |||
| d9b3e8dbe1 | |||
| a3b68dbb95 | |||
| 78d1965430 | |||
| 1de6984035 | |||
| 4042890b0a | |||
| 77498df63b | |||
| 6789879a2c | |||
| 3b9c1b8bdc | |||
| 0a111d9f85 | |||
| 3c2bb18537 | |||
| df19af99f9 | |||
| b5c88b2f1d | |||
| 2de1f1e35f | |||
| cc73a70f9e | |||
| 786f796223 | |||
| e7660edd79 | |||
| 1fe071f203 | |||
| c92d498b57 | |||
| 2911f3ac0e | |||
| 75dded78a1 | |||
| cab0347fd2 | |||
| b2f08f28d5 | |||
| 00d32ef182 | |||
| 6536c19c96 | |||
| 14bb8a017c | |||
| 5c68b24c7b | |||
| a43f3df4c1 | |||
| d961d1617a | |||
| 7b44e743a4 | |||
| 1ecb965981 | |||
| 1fe68e7367 | |||
| 22ad20337a | |||
| 89808c1f47 | |||
| fa404e98ec | |||
| eacaee493f | |||
| c03e2b319b | |||
| 36a27cb22c | |||
| 505dd5711e | |||
| 93e8393014 | |||
| 88e816c576 | |||
| 95ea4b764e | |||
| e17433e069 | |||
| 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Autopodbor;
|
||||
|
||||
class RunInFlightException extends \RuntimeException {}
|
||||
@@ -0,0 +1,580 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Dashboard\SupplyReconciliation;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
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: либо date_from/date_to (свой период, приоритет),
|
||||
* либо preset period=today|7d|30d|60d|90d (дефолт 7d). Возвращает [from, to]:
|
||||
* to — верхняя граница (конец дня date_to при своём периоде, иначе now).
|
||||
*
|
||||
* @return array{0:Carbon,1:Carbon}
|
||||
*/
|
||||
private function periodRange(Request $request): array
|
||||
{
|
||||
$df = (string) $request->query('date_from', '');
|
||||
$dt = (string) $request->query('date_to', '');
|
||||
if ($df !== '' && $dt !== '') {
|
||||
try {
|
||||
return [Carbon::parse($df)->startOfDay(), Carbon::parse($dt)->endOfDay()];
|
||||
} catch (\Throwable) {
|
||||
// невалидные даты → падаем на preset ниже
|
||||
}
|
||||
}
|
||||
|
||||
$from = 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),
|
||||
};
|
||||
|
||||
return [$from, now()];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard — сводка L1 (все плитки). */
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
[$from, $to] = $this->periodRange($request);
|
||||
|
||||
return response()->json([
|
||||
'period' => (string) $request->query('period', '7d'),
|
||||
'date_from' => $request->query('date_from'),
|
||||
'date_to' => $request->query('date_to'),
|
||||
'finance' => $this->financeTile($from, $to),
|
||||
'health' => $this->healthTile(),
|
||||
'leads' => $this->leadsTile(),
|
||||
'supply' => $this->supplyTile(),
|
||||
'balances' => $this->balancesTile(),
|
||||
'clients' => $this->clientsTile($from, $to),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function financeTile(Carbon $from, Carbon $to): array
|
||||
{
|
||||
$topups = (float) DB::table('balance_transactions')
|
||||
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
|
||||
$charges = (float) DB::table('balance_transactions')
|
||||
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
|
||||
$active = DB::table('tenants')->where('status', 'active')->whereNull('deleted_at')->count();
|
||||
$newClients = DB::table('tenants')->whereBetween('created_at', [$from, $to])->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, $to] = $this->periodRange($request);
|
||||
|
||||
$topups = (float) DB::table('balance_transactions')
|
||||
->where('type', 'topup')->whereBetween('created_at', [$from, $to])->sum('amount_rub');
|
||||
$charges = abs((float) DB::table('balance_transactions')
|
||||
->where('type', 'lead_charge')->whereBetween('created_at', [$from, $to])->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')
|
||||
->whereBetween('balance_transactions.created_at', [$from, $to])
|
||||
->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 распределения лидов + топ-10 последних (L2). */
|
||||
public function leads(): JsonResponse
|
||||
{
|
||||
$m = $this->leadsMetrics();
|
||||
|
||||
// Топ-10 последних лидов для drill (полный список — на экране /admin/leads).
|
||||
$recent = DB::table('supplier_leads as sl')
|
||||
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
|
||||
->orderByDesc('sl.received_at')
|
||||
->limit(10)
|
||||
->get(['sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.processed_at',
|
||||
'sl.deals_created_count', 'sp.signal_type as channel', 'sp.unique_key'])
|
||||
->map(fn ($r) => [
|
||||
'id' => (int) $r->id,
|
||||
'received_at' => $r->received_at,
|
||||
'platform' => $r->platform,
|
||||
'channel' => $r->channel,
|
||||
'source' => $r->unique_key,
|
||||
'phone_masked' => $this->maskPhoneShort($r->phone),
|
||||
'delivered' => ((int) ($r->deals_created_count ?? 0)) > 0,
|
||||
'processed' => $r->processed_at !== null,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'light' => $m['light'],
|
||||
'kpi' => [
|
||||
'delivered_today' => $m['delivered_today'],
|
||||
'received_today' => $m['received_today'],
|
||||
'stuck' => $m['stuck'],
|
||||
'unrouted' => $m['unrouted'],
|
||||
],
|
||||
'recent' => $recent,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Короткая маска телефона для drill (152-ФЗ). */
|
||||
private function maskPhoneShort(?string $phone): string
|
||||
{
|
||||
if (! $phone) {
|
||||
return '—';
|
||||
}
|
||||
$d = preg_replace('/\D/', '', $phone);
|
||||
|
||||
return strlen((string) $d) >= 4 ? substr((string) $d, 0, 2).'***'.substr((string) $d, -2) : '***';
|
||||
}
|
||||
|
||||
// === Этап 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'],
|
||||
];
|
||||
}
|
||||
|
||||
// === Балансы внешних сервисов (28.06) ===
|
||||
|
||||
/** Порядок «опасности» светофора: больше = хуже. */
|
||||
private const LIGHT_ORDER = ['green' => 0, 'grey' => 1, 'amber' => 2, 'red' => 3];
|
||||
|
||||
/**
|
||||
* Прямая ссылка «Пополнить» для сервиса (статика из конфига; в БД не хранится).
|
||||
* Владелец с планшета: увидел минус → ткнул → попал на страницу оплаты.
|
||||
*/
|
||||
private function topupUrl(string $key): ?string
|
||||
{
|
||||
return match ($key) {
|
||||
'dadata' => (string) config('services.dadata.topup_url') ?: null,
|
||||
'supplier' => (string) config('services.supplier.topup_url') ?: null,
|
||||
'yandex_cloud' => $this->ycTopupUrl(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function ycTopupUrl(): ?string
|
||||
{
|
||||
$base = (string) config('services.yandex_cloud.console_billing_url');
|
||||
$acc = (string) config('services.yandex_cloud.billing_account_id');
|
||||
if ($base === '' || $acc === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim($base, '/').'/'.$acc.'/payments';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function balancesTile(): array
|
||||
{
|
||||
$rows = DB::table('external_service_balances')->get();
|
||||
$light = $rows->isEmpty() ? 'grey'
|
||||
: $rows->map(fn ($r) => $r->ok ? $r->light : 'grey')
|
||||
->sortByDesc(fn ($l) => self::LIGHT_ORDER[$l] ?? 0)->first();
|
||||
|
||||
return [
|
||||
'light' => $light,
|
||||
'count' => $rows->count(),
|
||||
'red' => $rows->where('ok', true)->where('light', 'red')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/balances — балансы внешних сервисов (L2). */
|
||||
public function balances(): JsonResponse
|
||||
{
|
||||
$rows = DB::table('external_service_balances')->get()->map(fn ($r) => [
|
||||
'service_key' => $r->service_key,
|
||||
'balance_amount' => $r->balance_amount,
|
||||
'currency' => $r->currency,
|
||||
'daily_spend_estimate' => $r->daily_spend_estimate,
|
||||
'days_left' => $r->days_left,
|
||||
'light' => $r->ok ? $r->light : 'grey',
|
||||
'ok' => (bool) $r->ok,
|
||||
'error' => $r->error,
|
||||
'checked_at' => $r->checked_at,
|
||||
'topup_url' => $this->topupUrl($r->service_key),
|
||||
])->values();
|
||||
|
||||
$light = $rows->isEmpty() ? 'grey'
|
||||
: $rows->sortByDesc(fn ($s) => self::LIGHT_ORDER[$s['light']] ?? 0)->first()['light'];
|
||||
|
||||
return response()->json(['light' => $light, 'services' => $rows]);
|
||||
}
|
||||
|
||||
// === Клиенты (активность) ===
|
||||
|
||||
/** Клиент «спит», если его тенант не заходил дольше этого срока (или ни разу). */
|
||||
private const DORMANT_DAYS = 14;
|
||||
|
||||
/** @return array{total_active:int,new_count:int,logged_in:int,got_leads:int,paid:int} */
|
||||
private function clientActivityKpi(Carbon $from, Carbon $to): array
|
||||
{
|
||||
return [
|
||||
'total_active' => DB::table('tenants')->whereNull('deleted_at')->where('status', 'active')->count(),
|
||||
'new_count' => DB::table('tenants')->whereNull('deleted_at')->whereBetween('created_at', [$from, $to])->count(),
|
||||
'logged_in' => DB::table('users')->whereBetween('last_login_at', [$from, $to])->distinct()->count('tenant_id'),
|
||||
'got_leads' => DB::table('deals')->whereBetween('received_at', [$from, $to])->where('is_test', false)
|
||||
->whereNull('deleted_at')->distinct()->count('tenant_id'),
|
||||
'paid' => DB::table('balance_transactions')->where('type', 'topup')->whereBetween('created_at', [$from, $to])
|
||||
->distinct()->count('tenant_id'),
|
||||
];
|
||||
}
|
||||
|
||||
/** Активные тенанты без входа дольше DORMANT_DAYS (или ни разу) — «спящие». */
|
||||
private function dormantQuery(): Builder
|
||||
{
|
||||
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
|
||||
->groupBy('tenant_id');
|
||||
|
||||
return DB::table('tenants')
|
||||
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
|
||||
->whereNull('tenants.deleted_at')
|
||||
->where('tenants.status', 'active')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ll.last_login_at')
|
||||
->orWhere('ll.last_login_at', '<', now()->subDays(self::DORMANT_DAYS));
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function clientsTile(Carbon $from, Carbon $to): array
|
||||
{
|
||||
$kpi = $this->clientActivityKpi($from, $to);
|
||||
$dormant = (clone $this->dormantQuery())->count();
|
||||
|
||||
return [
|
||||
'light' => $dormant > 0 ? 'amber' : 'green',
|
||||
'total_active' => $kpi['total_active'],
|
||||
'new_count' => $kpi['new_count'],
|
||||
'logged_in' => $kpi['logged_in'],
|
||||
'dormant' => $dormant,
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/dashboard/clients — активность клиентов + новые + спящие (L2). */
|
||||
public function clients(Request $request): JsonResponse
|
||||
{
|
||||
[$from, $to] = $this->periodRange($request);
|
||||
$kpi = $this->clientActivityKpi($from, $to);
|
||||
|
||||
$lastLogin = DB::table('users')->select('tenant_id', DB::raw('MAX(last_login_at) as last_login_at'))
|
||||
->groupBy('tenant_id');
|
||||
|
||||
$newClients = DB::table('tenants')
|
||||
->leftJoinSub($lastLogin, 'll', 'll.tenant_id', '=', 'tenants.id')
|
||||
->whereNull('tenants.deleted_at')
|
||||
->whereBetween('tenants.created_at', [$from, $to])
|
||||
->orderByDesc('tenants.created_at')
|
||||
->limit(50)
|
||||
->get([
|
||||
'tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.status',
|
||||
'tenants.created_at', 'tenants.balance_rub', 'tenants.delivered_in_month', 'll.last_login_at',
|
||||
])
|
||||
->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'organization_name' => $t->organization_name ?: $t->subdomain,
|
||||
'subdomain' => $t->subdomain,
|
||||
'status' => $t->status,
|
||||
'created_at' => $t->created_at,
|
||||
'last_login_at' => $t->last_login_at,
|
||||
'delivered_in_month' => (int) $t->delivered_in_month,
|
||||
'balance_rub' => (string) $t->balance_rub,
|
||||
]);
|
||||
|
||||
$dormant = (clone $this->dormantQuery())
|
||||
->orderByRaw('ll.last_login_at ASC NULLS FIRST')
|
||||
->limit(50)
|
||||
->get(['tenants.id', 'tenants.organization_name', 'tenants.subdomain', 'tenants.balance_rub', 'll.last_login_at'])
|
||||
->map(fn ($t) => [
|
||||
'id' => (int) $t->id,
|
||||
'organization_name' => $t->organization_name ?: $t->subdomain,
|
||||
'subdomain' => $t->subdomain,
|
||||
'last_login_at' => $t->last_login_at,
|
||||
'balance_rub' => (string) $t->balance_rub,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'kpi' => $kpi,
|
||||
'new_clients' => $newClients,
|
||||
'dormant' => $dormant,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin «Лиды» (L3) — сквозная вложенность дашборда до конечного источника.
|
||||
* Серверная пагинация/фильтры (масштаб: десятки тысяч лидов).
|
||||
* Цепочка: supplier_leads.supplier_project_id → источник (канал+identifier),
|
||||
* platform = поставщик (B1/B2/B3), resolved_subject_code = регион,
|
||||
* deals.source_crm_id = supplier_leads.vid → сделки клиентов.
|
||||
* Группа ['saas-admin','admin-db'] → cross-tenant через pgsql_admin.
|
||||
* Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md
|
||||
*/
|
||||
class AdminLeadsController extends Controller
|
||||
{
|
||||
private const PER_PAGE_DEFAULT = 25;
|
||||
|
||||
private const PER_PAGE_MAX = 100;
|
||||
|
||||
private const STUCK_HOURS = 4;
|
||||
|
||||
/** Маска телефона по 152-ФЗ: «+7 9** *** ** 07» (видны код страны и 2 последние). */
|
||||
private function maskPhone(?string $phone): string
|
||||
{
|
||||
if (! $phone) {
|
||||
return '—';
|
||||
}
|
||||
$digits = preg_replace('/\D/', '', $phone);
|
||||
if (strlen((string) $digits) < 4) {
|
||||
return '***';
|
||||
}
|
||||
$last2 = substr((string) $digits, -2);
|
||||
$first = substr((string) $digits, 0, 2);
|
||||
|
||||
return $first.'** *** ** '.$last2;
|
||||
}
|
||||
|
||||
/** Производный статус лида для UI. */
|
||||
private function statusOf(object $r): string
|
||||
{
|
||||
if ($r->error !== null && $r->error !== '') {
|
||||
return 'error';
|
||||
}
|
||||
if ($r->processed_at !== null) {
|
||||
return ((int) ($r->deals_created_count ?? 0)) > 0 ? 'delivered' : 'no_match';
|
||||
}
|
||||
|
||||
return 'pending'; // визуально «завис» определяет фронт по времени, но базово pending
|
||||
}
|
||||
|
||||
/** Базовый запрос лидов с присоединённым источником (supplier_projects). */
|
||||
private function baseQuery(Request $request): Builder
|
||||
{
|
||||
$q = DB::table('supplier_leads as sl')
|
||||
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id');
|
||||
|
||||
if (($df = (string) $request->query('date_from', '')) !== '' && ($dt = (string) $request->query('date_to', '')) !== '') {
|
||||
$q->whereBetween('sl.received_at', [$df.' 00:00:00', $dt.' 23:59:59']);
|
||||
}
|
||||
if (($channel = (string) $request->query('channel', '')) !== '') {
|
||||
$q->where('sp.signal_type', $channel);
|
||||
}
|
||||
if (($platform = (string) $request->query('platform', '')) !== '') {
|
||||
$q->where('sl.platform', $platform);
|
||||
}
|
||||
if (($search = trim((string) $request->query('search', ''))) !== '') {
|
||||
$q->where(function ($w) use ($search) {
|
||||
$w->where('sl.phone', 'like', '%'.$search.'%')
|
||||
->orWhere('sp.unique_key', 'like', '%'.$search.'%')
|
||||
->orWhere('sl.vid', '=', ctype_digit($search) ? (int) $search : 0);
|
||||
});
|
||||
}
|
||||
if (($status = (string) $request->query('status', '')) !== '') {
|
||||
$this->applyStatusFilter($q, $status);
|
||||
}
|
||||
if (($tenantId = (int) $request->query('tenant_id', 0)) > 0) {
|
||||
$q->whereExists(function ($e) use ($tenantId) {
|
||||
$e->select(DB::raw(1))->from('deals')
|
||||
->whereColumn('deals.source_crm_id', 'sl.vid')
|
||||
->where('deals.tenant_id', $tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
private function applyStatusFilter(Builder $q, string $status): void
|
||||
{
|
||||
match ($status) {
|
||||
'error' => $q->whereNotNull('sl.error')->where('sl.error', '<>', ''),
|
||||
'delivered' => $q->whereNotNull('sl.processed_at')->where('sl.deals_created_count', '>', 0),
|
||||
'no_match' => $q->whereNotNull('sl.processed_at')
|
||||
->where(fn ($w) => $w->whereNull('sl.deals_created_count')->orWhere('sl.deals_created_count', '=', 0)),
|
||||
'stuck' => $q->whereNull('sl.processed_at')->where('sl.received_at', '<', now()->subHours(self::STUCK_HOURS)),
|
||||
'pending' => $q->whereNull('sl.processed_at'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function rowToArray(object $r): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $r->id,
|
||||
'received_at' => $r->received_at,
|
||||
'platform' => $r->platform,
|
||||
'channel' => $r->channel,
|
||||
'source' => $r->unique_key,
|
||||
'region_code' => $r->resolved_subject_code !== null ? (int) $r->resolved_subject_code : null,
|
||||
'phone_masked' => $this->maskPhone($r->phone),
|
||||
'deals_created_count' => (int) ($r->deals_created_count ?? 0),
|
||||
'status' => $this->statusOf($r),
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/admin/leads — серверный список с фильтрами/пагинацией. */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->query('per_page', self::PER_PAGE_DEFAULT)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$base = $this->baseQuery($request);
|
||||
$total = (clone $base)->count();
|
||||
|
||||
$rows = $base
|
||||
->orderByDesc('sl.received_at')
|
||||
->offset(($page - 1) * $perPage)
|
||||
->limit($perPage)
|
||||
->get([
|
||||
'sl.id', 'sl.received_at', 'sl.platform', 'sl.phone', 'sl.deals_created_count',
|
||||
'sl.processed_at', 'sl.error', 'sl.resolved_subject_code',
|
||||
'sp.signal_type as channel', 'sp.unique_key',
|
||||
])
|
||||
->map(fn ($r) => $this->rowToArray($r));
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/admin/leads/{id} — карточка лида: источник + сделки клиентов (цепочка). */
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$lead = DB::table('supplier_leads as sl')
|
||||
->leftJoin('supplier_projects as sp', 'sp.id', '=', 'sl.supplier_project_id')
|
||||
->where('sl.id', $id)
|
||||
->first([
|
||||
'sl.id', 'sl.received_at', 'sl.processed_at', 'sl.error', 'sl.platform', 'sl.phone',
|
||||
'sl.vid', 'sl.deals_created_count', 'sl.resolved_subject_code', 'sl.region_source',
|
||||
'sl.phone_operator', 'sp.signal_type as channel', 'sp.unique_key', 'sp.id as supplier_project_id',
|
||||
]);
|
||||
|
||||
if ($lead === null) {
|
||||
return response()->json(['message' => 'Лид не найден'], 404);
|
||||
}
|
||||
|
||||
$deals = DB::table('deals')
|
||||
->join('tenants', 'tenants.id', '=', 'deals.tenant_id')
|
||||
->where('deals.source_crm_id', $lead->vid)
|
||||
->orderByDesc('deals.received_at')
|
||||
->limit(50)
|
||||
->get([
|
||||
'deals.id', 'deals.tenant_id', 'tenants.organization_name', 'tenants.subdomain',
|
||||
'deals.status', 'deals.project_id', 'deals.received_at',
|
||||
])
|
||||
->map(fn ($d) => [
|
||||
'id' => (int) $d->id,
|
||||
'tenant_id' => (int) $d->tenant_id,
|
||||
'tenant_name' => $d->organization_name ?: $d->subdomain,
|
||||
'subdomain' => $d->subdomain,
|
||||
'status' => $d->status,
|
||||
'project_id' => $d->project_id !== null ? (int) $d->project_id : null,
|
||||
'received_at' => $d->received_at,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'lead' => [
|
||||
'id' => (int) $lead->id,
|
||||
'platform' => $lead->platform,
|
||||
'phone_masked' => $this->maskPhone($lead->phone),
|
||||
'received_at' => $lead->received_at,
|
||||
'processed_at' => $lead->processed_at,
|
||||
'error' => $lead->error,
|
||||
'region_code' => $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
|
||||
'region_source' => $lead->region_source,
|
||||
'phone_operator' => $lead->phone_operator,
|
||||
'deals_created_count' => (int) ($lead->deals_created_count ?? 0),
|
||||
'status' => $this->statusOf($lead),
|
||||
],
|
||||
'source' => [
|
||||
'platform' => $lead->platform,
|
||||
'channel' => $lead->channel,
|
||||
'identifier' => $lead->unique_key,
|
||||
'supplier_project_id' => $lead->supplier_project_id !== null ? (int) $lead->supplier_project_id : null,
|
||||
],
|
||||
'deals' => $deals,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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) + дата последней поставки лида.
|
||||
|
||||
@@ -30,10 +30,14 @@ class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
|
||||
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$status = (string) $request->query('status', '');
|
||||
// statuses — производные статусы UI (trial/overdue/active/suspended), csv, multi.
|
||||
// tariffs — имена тарифов (tariff_plans.name), csv, multi.
|
||||
$statuses = $this->csvParam($request, 'statuses');
|
||||
$tariffs = $this->csvParam($request, 'tariffs');
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
@@ -59,8 +63,22 @@ class AdminTenantsController extends Controller
|
||||
])
|
||||
->whereNull('tenants.deleted_at');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('tenants.status', $status);
|
||||
// Производный статус — зеркалит adminTenantsMapper.deriveStatus (фронт):
|
||||
// trial > suspended > overdue > active. Серверная фильтрация нужна для масштаба
|
||||
// (1000 клиентов): без неё чипы фильтровали бы только загруженную страницу.
|
||||
if ($statuses !== []) {
|
||||
$query->whereIn(DB::raw("(CASE
|
||||
WHEN tenants.is_trial THEN 'trial'
|
||||
WHEN tenants.status = 'suspended' THEN 'suspended'
|
||||
WHEN tenants.chargeback_unrecovered_rub > 0 OR tenants.balance_rub < 0 THEN 'overdue'
|
||||
WHEN tenants.status = 'active' THEN 'active'
|
||||
ELSE 'suspended'
|
||||
END)"), $statuses);
|
||||
} elseif ($status !== '') {
|
||||
$query->where('tenants.status', $status); // back-compat: фильтр по сырой колонке
|
||||
}
|
||||
if ($tariffs !== []) {
|
||||
$query->whereIn('tariff_plans.name', $tariffs);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$like = '%'.$search.'%';
|
||||
@@ -451,6 +469,19 @@ class AdminTenantsController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает csv-параметр запроса в список непустых trimmed-строк.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function csvParam(Request $request, string $key): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) $request->query($key, '')),
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate-stats для page-head: total / active / trial / overdue / revenue.
|
||||
* Считается отдельным запросом без фильтров (показывает глобальную картину
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Autopodbor\CompetitorResource;
|
||||
use App\Http\Resources\Autopodbor\RunResource;
|
||||
use App\Http\Resources\Autopodbor\SourceResource;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Services\Autopodbor\AutopodborProjectCreator;
|
||||
use App\Services\Autopodbor\AutopodborRunService;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Клиентский API автоподбора конкурентов.
|
||||
*
|
||||
* Auth: auth:sanctum + tenant middleware (устанавливает app.current_tenant_id для RLS).
|
||||
* Все выборки дополнительно скоупятся по tenant_id (пояс+подтяжки к RLS).
|
||||
*/
|
||||
class AutopodborController extends Controller
|
||||
{
|
||||
/** GET /api/autopodbor/state */
|
||||
public function state(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$runs = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->orderByDesc('id')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'enabled' => SystemSettings::bool('autopodbor_enabled'),
|
||||
'runs' => RunResource::collection($runs),
|
||||
'prices' => [
|
||||
'search' => (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0'),
|
||||
'study' => (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/runs/{run} */
|
||||
public function run(Request $request, int $run): JsonResponse
|
||||
{
|
||||
$r = AutopodborRun::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($run);
|
||||
|
||||
return response()->json(['data' => new RunResource($r)]);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/competitors/{competitor} */
|
||||
public function competitor(Request $request, int $competitor, AutopodborDedup $dedup): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->with('sources.project')
|
||||
->findOrFail($competitor);
|
||||
|
||||
$sources = $comp->sources->map(function (AutopodborSource $s) use ($dedup) {
|
||||
$existingProjectId = $s->created_project_id
|
||||
?? $dedup->existingProjectId($s->tenant_id, $s->signal_type, $s->identifier);
|
||||
|
||||
return array_merge(
|
||||
(new SourceResource($s))->resolve(),
|
||||
[
|
||||
'existing_project_id' => $existingProjectId,
|
||||
'project' => $this->projectStatus($s->project),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => new CompetitorResource($comp),
|
||||
'sources' => $sources,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/runs/{run}/competitors */
|
||||
public function runCompetitors(Request $request, int $run): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
// убедимся, что прогон принадлежит tenant (404 если чужой)
|
||||
AutopodborRun::where('tenant_id', $tenantId)->findOrFail($run);
|
||||
|
||||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->where('search_run_id', $run)
|
||||
->orderByDesc('relevance_pct')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => CompetitorResource::collection($competitors)]);
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/search */
|
||||
public function search(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'region_code' => 'required|integer',
|
||||
'examples' => 'array',
|
||||
'about_self' => 'array',
|
||||
'include_federal' => 'boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $svc->startSearch(
|
||||
$request->user()->tenant_id,
|
||||
(int) $v['region_code'],
|
||||
$v['examples'] ?? [],
|
||||
$v['about_self'] ?? [],
|
||||
(bool) ($v['include_federal'] ?? false),
|
||||
);
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
} catch (InsufficientBalanceException) {
|
||||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/study */
|
||||
public function study(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'competitor_id' => 'required|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $svc->startStudy(
|
||||
$request->user()->tenant_id,
|
||||
(int) $v['competitor_id'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
} catch (InsufficientBalanceException) {
|
||||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/resolve */
|
||||
public function resolve(Request $request, AutopodborRunService $svc): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'name' => 'required|string',
|
||||
'region_code' => 'required|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$run = $svc->startResolve(
|
||||
$request->user()->tenant_id,
|
||||
$v['name'],
|
||||
(int) $v['region_code'],
|
||||
);
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/manual-study */
|
||||
public function manualStudy(Request $request, AutopodborRunService $svc, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'competitor_id' => ['nullable', 'integer'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'site_url' => ['nullable', 'string', 'max:500'],
|
||||
'directory' => ['nullable', 'string', 'max:500'],
|
||||
'region_code' => ['required', 'integer'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
try {
|
||||
if (! empty($v['competitor_id'])) {
|
||||
$run = $svc->startStudy($uid, (int) $v['competitor_id']);
|
||||
} else {
|
||||
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
|
||||
$name = ! empty($v['name']) ? $v['name'] : ($site ?? 'Конкурент');
|
||||
if (empty($v['name']) && $site === null) {
|
||||
return response()->json(['error' => 'name_or_site_required'], 422);
|
||||
}
|
||||
$run = $svc->startManualStudy($uid, [
|
||||
'name' => $name,
|
||||
'site_url' => $site,
|
||||
'directory_urls' => ! empty($v['directory']) ? [$v['directory']] : [],
|
||||
], (int) $v['region_code']);
|
||||
}
|
||||
} catch (RunInFlightException) {
|
||||
return response()->json(['error' => 'run_in_flight'], 409);
|
||||
} catch (InsufficientBalanceException) {
|
||||
return response()->json(['error' => 'balance_insufficient'], 409);
|
||||
}
|
||||
|
||||
return response()->json(['data' => new RunResource($run)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/autopodbor/field — рабочее место «Конкурентное поле».
|
||||
* Конкуренты в ящике «поле» с их источниками в поле, статусом проекта по каждому
|
||||
* источнику и счётчиками (источников / создано проектов / в работе).
|
||||
*/
|
||||
public function field(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = $request->user()->tenant_id;
|
||||
|
||||
$competitors = AutopodborCompetitor::where('tenant_id', $tenantId)
|
||||
->where('box', 'field')
|
||||
->with(['sources' => function ($q) {
|
||||
$q->where('box', 'field')->with('project');
|
||||
}])
|
||||
->orderByDesc('relevance_pct')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$payload = $competitors->map(function (AutopodborCompetitor $comp) {
|
||||
$sources = $comp->sources->map(fn (AutopodborSource $s) => array_merge(
|
||||
(new SourceResource($s))->resolve(),
|
||||
['project' => $this->projectStatus($s->project)],
|
||||
));
|
||||
|
||||
$created = $comp->sources->filter(fn ($s) => $s->project !== null);
|
||||
$inWork = $created->filter(
|
||||
fn ($s) => $s->project->is_active && $s->project->preflight_blocked_at === null
|
||||
);
|
||||
|
||||
return array_merge(
|
||||
(new CompetitorResource($comp))->resolve(),
|
||||
[
|
||||
'counters' => [
|
||||
'sources' => $comp->sources->count(),
|
||||
'projects_created' => $created->count(),
|
||||
'projects_in_work' => $inWork->count(),
|
||||
],
|
||||
'sources' => $sources,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
return response()->json(['competitors' => $payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/autopodbor/competitors/manual — завести конкурента вручную сразу В ПОЛЕ,
|
||||
* без запуска изучения (§14.2 «+ Добавить вручную»). Изучение источников — отдельно, по кнопке.
|
||||
*/
|
||||
public function manualCompetitor(Request $request, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:2000'],
|
||||
'is_federal' => ['boolean'],
|
||||
'relevance_pct' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||
'site_url' => ['nullable', 'string', 'max:500'],
|
||||
'directory' => ['nullable', 'string', 'max:500'],
|
||||
'directory_urls' => ['nullable', 'array'],
|
||||
'directory_urls.*' => ['string', 'max:500'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
$site = ! empty($v['site_url']) ? $norm->domainHead($v['site_url']) : null;
|
||||
|
||||
$dirs = $v['directory_urls'] ?? (! empty($v['directory']) ? [$v['directory']] : []);
|
||||
$dirs = array_values(array_filter(array_map('trim', $dirs)));
|
||||
|
||||
$comp = AutopodborCompetitor::create([
|
||||
'tenant_id' => $uid,
|
||||
'search_run_id' => null,
|
||||
'name' => $v['name'],
|
||||
'description' => $v['description'] ?? null,
|
||||
'is_federal' => (bool) ($v['is_federal'] ?? false),
|
||||
'relevance_pct' => $v['relevance_pct'] ?? null,
|
||||
'origin' => 'manual',
|
||||
'box' => 'field',
|
||||
'site_url' => $site,
|
||||
'directory_urls' => $dirs,
|
||||
'dedup_key' => $norm->competitorKey($v['name'], $site),
|
||||
]);
|
||||
|
||||
return response()->json(['data' => new CompetitorResource($comp)], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/autopodbor/competitors/{id} — правка полей карточки конкурента */
|
||||
public function updateCompetitor(Request $request, int $competitor): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
|
||||
'is_federal' => ['sometimes', 'boolean'],
|
||||
'relevance_pct' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'],
|
||||
'site_url' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'directory_urls' => ['sometimes', 'array'],
|
||||
'directory_urls.*' => ['string', 'max:500'],
|
||||
'box' => ['sometimes', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($competitor);
|
||||
|
||||
$comp->update($v);
|
||||
|
||||
return response()->json(['data' => new CompetitorResource($comp)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/autopodbor/competitors/{id} — удаление конкурента и его источников.
|
||||
* Блокируется, если у любого источника есть активный созданный проект
|
||||
* (управлять проектом нужно через раздел проектов — §14.10).
|
||||
*/
|
||||
public function destroyCompetitor(Request $request, int $competitor): JsonResponse
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->with('sources.project')
|
||||
->findOrFail($competitor);
|
||||
|
||||
$hasActive = $comp->sources->contains(
|
||||
fn (AutopodborSource $s) => $s->project && $s->project->is_active
|
||||
);
|
||||
|
||||
if ($hasActive) {
|
||||
return response()->json(['error' => 'has_active_projects'], 409);
|
||||
}
|
||||
|
||||
$comp->sources()->delete();
|
||||
$comp->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/** GET /api/autopodbor/proposals — конкуренты в ящике «предложения», сорт по похожести. */
|
||||
public function proposals(Request $request): JsonResponse
|
||||
{
|
||||
$competitors = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->where('box', 'proposal')
|
||||
->orderByDesc('relevance_pct')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => CompetitorResource::collection($competitors)]);
|
||||
}
|
||||
|
||||
/** PATCH /api/autopodbor/competitors/{id}/box — перенос конкурента предложение↔поле */
|
||||
public function competitorBox(Request $request, int $competitor): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'box' => ['required', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($competitor);
|
||||
|
||||
$comp->update(['box' => $v['box']]);
|
||||
|
||||
return response()->json(['data' => new CompetitorResource($comp)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/autopodbor/sources/{id} — правка значения/провенанса/ящика источника.
|
||||
* Тип источника (signal_type) НЕИЗМЕНЯЕМ (как в ProjectService — молча игнорируем).
|
||||
* Смена самого значения (identifier) у источника с активным проектом запрещена —
|
||||
* это смена источника проекта, делается через раздел проектов (§14.10).
|
||||
*/
|
||||
public function updateSource(Request $request, int $source): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'identifier' => ['sometimes', 'string', 'max:500'],
|
||||
'phone_kind' => ['sometimes', 'nullable', 'string', 'in:real,substitute'],
|
||||
'phone_type' => ['sometimes', 'nullable', 'string', 'in:city,mobile,tollfree'],
|
||||
'provenance_url' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||
'provenance_label' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'box' => ['sometimes', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||||
->with('project')
|
||||
->findOrFail($source);
|
||||
|
||||
$changesIdentifier = array_key_exists('identifier', $v) && $v['identifier'] !== $src->identifier;
|
||||
if ($changesIdentifier && $src->project && $src->project->is_active) {
|
||||
return response()->json(['error' => 'manage_via_project'], 409);
|
||||
}
|
||||
|
||||
$src->update($v);
|
||||
|
||||
return response()->json(['data' => new SourceResource($src)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/autopodbor/sources/{id} — удаление источника.
|
||||
* Блокируется, если у источника есть активный созданный проект (§14.10).
|
||||
*/
|
||||
public function destroySource(Request $request, int $source): JsonResponse
|
||||
{
|
||||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||||
->with('project')
|
||||
->findOrFail($source);
|
||||
|
||||
if ($src->project && $src->project->is_active) {
|
||||
return response()->json(['error' => 'has_active_project'], 409);
|
||||
}
|
||||
|
||||
$src->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/** PATCH /api/autopodbor/sources/{id}/box — перенос источника предложение↔в работу */
|
||||
public function sourceBox(Request $request, int $source): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'box' => ['required', 'string', 'in:proposal,field'],
|
||||
]);
|
||||
|
||||
$src = AutopodborSource::where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($source);
|
||||
|
||||
$src->update(['box' => $v['box']]);
|
||||
|
||||
return response()->json(['data' => new SourceResource($src)]);
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/sources/manual */
|
||||
public function addManualSource(Request $request, AutopodborNormalizer $norm): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'competitor_id' => ['required', 'integer'],
|
||||
'raw' => ['required', 'string', 'max:500'],
|
||||
]);
|
||||
$uid = $request->user()->tenant_id;
|
||||
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $uid)->findOrFail((int) $v['competitor_id']);
|
||||
if ($comp->study_run_id === null) {
|
||||
return response()->json(['error' => 'not_studied'], 422);
|
||||
}
|
||||
|
||||
$raw = trim($v['raw']);
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
$isCall = strlen($digits) >= 10;
|
||||
$signalType = $isCall ? 'call' : 'site';
|
||||
$identifier = $isCall ? $norm->phone($raw) : $norm->domainHead($raw);
|
||||
|
||||
$source = AutopodborSource::updateOrCreate(
|
||||
['competitor_id' => $comp->id, 'dedup_key' => $norm->sourceKey($signalType, $raw)],
|
||||
[
|
||||
'tenant_id' => $uid,
|
||||
'study_run_id' => $comp->study_run_id,
|
||||
'signal_type' => $signalType,
|
||||
'identifier' => $identifier,
|
||||
'phone_kind' => $isCall ? 'real' : null,
|
||||
'provenance_url' => null,
|
||||
'provenance_label' => 'Добавлено вручную',
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json(['data' => new SourceResource($source)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус проекта источника для UI (пауза/работа/блок). null — проекта нет.
|
||||
*
|
||||
* @return array{id: int, name: string, is_active: bool, paused_at: ?string, preflight_blocked_at: ?string}|null
|
||||
*/
|
||||
private function projectStatus(?Project $project): ?array
|
||||
{
|
||||
if ($project === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'signal_identifier' => $project->signal_identifier,
|
||||
'is_active' => (bool) $project->is_active,
|
||||
'paused_at' => $project->paused_at?->toIso8601String(),
|
||||
'preflight_blocked_at' => $project->preflight_blocked_at?->toIso8601String(),
|
||||
'daily_limit_target' => (int) $project->daily_limit_target,
|
||||
'delivered_in_month' => (int) $project->delivered_in_month,
|
||||
'delivery_days_mask' => (int) $project->delivery_days_mask,
|
||||
'regions' => $project->regions ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/** POST /api/autopodbor/projects */
|
||||
public function createProjects(Request $request, AutopodborProjectCreator $creator): JsonResponse
|
||||
{
|
||||
$v = $request->validate([
|
||||
'source_ids' => 'required|array',
|
||||
'source_ids.*' => 'integer',
|
||||
'regions' => 'array',
|
||||
'regions.*' => 'integer',
|
||||
'daily_limit_target' => 'required|integer',
|
||||
'delivery_days_mask' => 'required|integer',
|
||||
'launch' => 'boolean',
|
||||
]);
|
||||
|
||||
$tenant = $request->user()->tenant;
|
||||
$launch = (bool) ($v['launch'] ?? false);
|
||||
|
||||
// Балансовый preflight при launch=true
|
||||
if ($launch) {
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$wouldBe = $existingLimit + count($v['source_ids']) * (int) $v['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBe);
|
||||
|
||||
if (! $preflight['passes']) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBe,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
}
|
||||
|
||||
$projects = $creator->createFromSources(
|
||||
$tenant->id,
|
||||
$v['source_ids'],
|
||||
[
|
||||
'regions' => $v['regions'] ?? [],
|
||||
'daily_limit_target' => (int) $v['daily_limit_target'],
|
||||
'delivery_days_mask' => (int) $v['delivery_days_mask'],
|
||||
],
|
||||
$launch,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => collect($projects)->map(fn ($p) => ['id' => $p->id, 'name' => $p->name])->all(),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Копия helper'а из ProjectController — балансовый preflight.
|
||||
*
|
||||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// preflight пропускаем (legacy-окружения / тесты).
|
||||
if ($tiers->isEmpty()) {
|
||||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||||
}
|
||||
|
||||
$result = (new BalancePreflightService)->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $requiredLeads,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
return [
|
||||
'passes' => $result->passes,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'deficit_leads' => $result->deficitLeads,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,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,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Autopodbor;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CompetitorResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'is_federal' => $this->is_federal,
|
||||
'relevance_pct' => $this->relevance_pct,
|
||||
'origin' => $this->origin,
|
||||
'box' => $this->box,
|
||||
'site_url' => $this->site_url,
|
||||
'directory_urls' => $this->directory_urls,
|
||||
'studied_at' => $this->studied_at?->toIso8601String(),
|
||||
'study_run_id' => $this->study_run_id,
|
||||
'search_run_id' => $this->search_run_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborSource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class RunResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'kind' => $this->kind,
|
||||
'competitor_id' => $this->competitor_id,
|
||||
'status' => $this->status,
|
||||
'region_code' => $this->region_code,
|
||||
'params' => $this->params,
|
||||
'price_rub_charged' => $this->price_rub_charged,
|
||||
'error_code' => $this->error_code,
|
||||
'competitors_count' => AutopodborCompetitor::where('search_run_id', $this->id)->count(),
|
||||
'sources_count' => AutopodborSource::where('study_run_id', $this->id)->count(),
|
||||
'started_at' => $this->started_at?->toIso8601String(),
|
||||
'finished_at' => $this->finished_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Autopodbor;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SourceResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'competitor_id' => $this->competitor_id,
|
||||
'signal_type' => $this->signal_type,
|
||||
'identifier' => $this->identifier,
|
||||
'phone_kind' => $this->phone_kind,
|
||||
'phone_type' => $this->phone_type,
|
||||
'box' => $this->box,
|
||||
'provenance_url' => $this->provenance_url,
|
||||
'provenance_label' => $this->provenance_label,
|
||||
'created_project_id' => $this->created_project_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RunAutopodborResolveJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
public function __construct(public int $runId) {}
|
||||
|
||||
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup): void
|
||||
{
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
// Выставляем tenant-контекст сессионно
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
|
||||
|
||||
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
|
||||
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->update(['status' => 'running', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
$p = $run->params;
|
||||
|
||||
$res = $agent->resolveByName(new ResolveByNameRequest(
|
||||
name: $p['name'],
|
||||
regionCode: (int) $run->region_code,
|
||||
));
|
||||
|
||||
$unique = $dedup->dedupCompetitors($res->candidates);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($unique as $c) {
|
||||
AutopodborCompetitor::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'search_run_id' => $run->id,
|
||||
'dedup_key' => $c['dedup_key'],
|
||||
],
|
||||
[
|
||||
'name' => $c['name'],
|
||||
'description' => $c['description'] ?? null,
|
||||
'is_federal' => (bool) ($c['is_federal'] ?? false),
|
||||
'relevance_pct' => null,
|
||||
'origin' => 'resolve',
|
||||
'site_url' => $c['site_url'] ?? null,
|
||||
'directory_urls' => $c['directory_urls'] ?? [],
|
||||
'provenance' => $c['provenance'] ?? [],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$run->update(['status' => 'done', 'finished_at' => now()]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborChargeService;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RunAutopodborSearchJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
public function __construct(public int $runId) {}
|
||||
|
||||
public function handle(CompetitorAgent $agent, AutopodborDedup $dedup, AutopodborChargeService $charge): void
|
||||
{
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
|
||||
|
||||
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
|
||||
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->update(['status' => 'running', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
$p = $run->params;
|
||||
$max = (int) (SystemSettings::get('autopodbor_max_competitors') ?? 15);
|
||||
|
||||
$res = $agent->findCompetitors(new FindCompetitorsRequest(
|
||||
regionCode: (int) $run->region_code,
|
||||
examples: $p['examples'] ?? [],
|
||||
aboutSelf: $p['about_self'] ?? [],
|
||||
includeFederal: (bool) ($p['include_federal'] ?? false),
|
||||
maxCompetitors: $max,
|
||||
));
|
||||
|
||||
$unique = $dedup->dedupCompetitors($res->competitors);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_slice($unique, 0, $max) as $c) {
|
||||
AutopodborCompetitor::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'search_run_id' => $run->id,
|
||||
'dedup_key' => $c['dedup_key'],
|
||||
],
|
||||
[
|
||||
'name' => $c['name'],
|
||||
'description' => $c['description'] ?? null,
|
||||
'is_federal' => (bool) ($c['is_federal'] ?? false),
|
||||
'relevance_pct' => $c['relevance_pct'] ?? null,
|
||||
'origin' => 'auto',
|
||||
'site_url' => $c['site_url'] ?? null,
|
||||
'directory_urls' => $c['directory_urls'] ?? [],
|
||||
'provenance' => $c['provenance'] ?? [],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$price = (string) (SystemSettings::get('autopodbor_price_search_rub') ?? '0');
|
||||
$charge->chargeForRun($run, $price);
|
||||
|
||||
$run->update(['status' => 'done', 'finished_at' => now()]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\AutopodborChargeService;
|
||||
use App\Services\Autopodbor\AutopodborDedup;
|
||||
use App\Services\Autopodbor\AutopodborNormalizer;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RunAutopodborStudyJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public array $backoff = [15, 60, 300];
|
||||
|
||||
public function __construct(public int $runId) {}
|
||||
|
||||
public function handle(
|
||||
CompetitorAgent $agent,
|
||||
AutopodborDedup $dedup,
|
||||
AutopodborChargeService $charge,
|
||||
AutopodborNormalizer $norm,
|
||||
): void {
|
||||
$run = AutopodborRun::findOrFail($this->runId);
|
||||
|
||||
// Выставляем tenant-контекст сессионно — все запросы (включая вложенные транзакции charge) видят GUC
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $run->tenant_id]);
|
||||
|
||||
// Идемпотентность: завершённый прогон повторно не обрабатываем (защита от лишнего ретрая/повторного dispatch).
|
||||
if (in_array($run->status, ['done', 'empty', 'failed'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->update(['status' => 'running', 'started_at' => now()]);
|
||||
|
||||
try {
|
||||
$comp = AutopodborCompetitor::findOrFail($run->competitor_id);
|
||||
|
||||
$res = $agent->studyCompetitor(new StudyCompetitorRequest(
|
||||
competitor: [
|
||||
'name' => $comp->name,
|
||||
'site_url' => $comp->site_url,
|
||||
'directory_urls' => $comp->directory_urls ?? [],
|
||||
],
|
||||
regionCode: (int) $run->region_code,
|
||||
));
|
||||
|
||||
$unique = $dedup->dedupSources($res->sources);
|
||||
|
||||
if ($unique === []) {
|
||||
$run->update(['status' => 'empty', 'finished_at' => now()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($unique as $s) {
|
||||
$identifier = $s['signal_type'] === 'call'
|
||||
? $norm->phone($s['identifier'])
|
||||
: $norm->domainHead($s['identifier']);
|
||||
|
||||
AutopodborSource::updateOrCreate(
|
||||
[
|
||||
'competitor_id' => $comp->id,
|
||||
'dedup_key' => $s['dedup_key'],
|
||||
],
|
||||
[
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'study_run_id' => $run->id,
|
||||
'signal_type' => $s['signal_type'],
|
||||
'identifier' => $identifier,
|
||||
'phone_kind' => $s['phone_kind'] ?? null,
|
||||
'phone_type' => $s['phone_type'] ?? null,
|
||||
'provenance_url' => $s['provenance_url'] ?? null,
|
||||
'provenance_label' => $s['provenance_label'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$price = (string) (SystemSettings::get('autopodbor_price_study_rub') ?? '0');
|
||||
$charge->chargeForRun($run, $price);
|
||||
|
||||
$comp->update(['studied_at' => now(), 'study_run_id' => $run->id]);
|
||||
|
||||
$run->update(['status' => 'done', 'finished_at' => now()]);
|
||||
} catch (\Throwable $e) {
|
||||
$run->update([
|
||||
'status' => 'failed',
|
||||
'error_code' => substr($e->getMessage(), 0, 64),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
Tenant::query()
|
||||
// Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS).
|
||||
// Список замороженных тенантов брать через дефолтное соединение нельзя — без
|
||||
// app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же
|
||||
// баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS).
|
||||
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
|
||||
->whereNotNull('frozen_by_balance_at')
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->processTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$this->processTenant((int) $tenantId, $service, $tiers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
// SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find,
|
||||
// и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent()
|
||||
// идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен.
|
||||
DB::transaction(function () use ($tenantId, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
|
||||
return; // разморожен/удалён между pluck и обработкой.
|
||||
}
|
||||
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
|
||||
// Re-evaluate для актуального дефицита в тексте письма.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $tenant->requiredLeadsForTomorrow(),
|
||||
tiers: $tiers,
|
||||
);
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
|
||||
$mail = $window === 'reminder'
|
||||
? new BalanceFrozenReminderMail($tenant, $result)
|
||||
: new BalanceFrozenFinalMail($tenant, $result);
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::queue($mail);
|
||||
$this->mark($tenant, $marker, $result);
|
||||
// Re-evaluate для актуального дефицита в тексте письма.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $tenant->requiredLeadsForTomorrow(),
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$mail = $window === 'reminder'
|
||||
? new BalanceFrozenReminderMail($tenant, $result)
|
||||
: new BalanceFrozenFinalMail($tenant, $result);
|
||||
|
||||
Mail::queue($mail);
|
||||
$this->mark($tenant, $marker, $result);
|
||||
});
|
||||
}
|
||||
|
||||
private function matchWindow(int $hours): ?string
|
||||
|
||||
@@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->evaluateTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
// Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user
|
||||
// (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без
|
||||
// app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и
|
||||
// sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id
|
||||
// через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает
|
||||
// для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст.
|
||||
$tenantIds = DB::connection('pgsql_supplier')->table('tenants')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$this->evaluateTenant((int) $tenantId, $service, $tiers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
|
||||
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
|
||||
// RLS-policy на projects падает с "unrecognized configuration parameter".
|
||||
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
DB::transaction(function () use ($tenant, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
|
||||
DB::transaction(function () use ($tenantId, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
// Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find
|
||||
// вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна.
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null) {
|
||||
return; // удалён между pluck и обработкой — пропускаем.
|
||||
}
|
||||
|
||||
$required = $tenant->requiredLeadsForTomorrow();
|
||||
$result = $service->evaluate(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\External;
|
||||
|
||||
use App\Services\Dashboard\BalanceHealth;
|
||||
use App\Services\External\BalanceProvider;
|
||||
use App\Services\External\DadataBalanceProvider;
|
||||
use App\Services\External\SupplierBalanceProvider;
|
||||
use App\Services\External\YandexCloudBalanceProvider;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
|
||||
* Каждый провайдер изолирован: fetch() не бросает; ok=false оставляет ПРОШЛЫЙ баланс
|
||||
* + метку ошибки (плитка не падает, показывает «данные от ДАТА»). Пишет под
|
||||
* crm_supplier_worker (BYPASSRLS) — таблица системная, как supplier_sync_runs.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
|
||||
*/
|
||||
class RefreshExternalBalancesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS для записи системной таблицы
|
||||
|
||||
/** @return array<int,class-string<BalanceProvider>> */
|
||||
private function providers(): array
|
||||
{
|
||||
return [
|
||||
DadataBalanceProvider::class,
|
||||
SupplierBalanceProvider::class,
|
||||
YandexCloudBalanceProvider::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
foreach ($this->providers() as $cls) {
|
||||
/** @var BalanceProvider $p */
|
||||
$p = app($cls);
|
||||
$key = $p->serviceKey();
|
||||
$reading = $p->fetch(); // не бросает
|
||||
|
||||
// Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера
|
||||
// накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert
|
||||
// ошибочно идёт в INSERT существующей строки → нарушение PK.
|
||||
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
|
||||
|
||||
if (! $reading->ok) {
|
||||
// Оставляем прошлый баланс, помечаем ok=false + ошибку.
|
||||
$table->updateOrInsert(
|
||||
['service_key' => $key],
|
||||
[
|
||||
'ok' => false,
|
||||
'error' => $reading->error,
|
||||
'checked_at' => $reading->checkedAt,
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$red, $amber] = $this->floors($key);
|
||||
$h = BalanceHealth::evaluate((float) $reading->balance, $reading->dailySpend, $red, $amber);
|
||||
|
||||
$table->updateOrInsert(
|
||||
['service_key' => $key],
|
||||
[
|
||||
'balance_amount' => $reading->balance,
|
||||
'currency' => $reading->currency,
|
||||
'daily_spend_estimate' => $reading->dailySpend,
|
||||
'days_left' => $h['days_left'],
|
||||
'light' => $h['light'],
|
||||
'ok' => true,
|
||||
'error' => null,
|
||||
'checked_at' => $reading->checkedAt,
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:float,1:float} [red_floor, amber_floor] */
|
||||
private function floors(string $key): array
|
||||
{
|
||||
return match ($key) {
|
||||
'dadata' => [
|
||||
(float) config('services.dadata.red_floor_rub', 500),
|
||||
(float) config('services.dadata.amber_floor_rub', 2000),
|
||||
],
|
||||
'yandex_cloud' => [
|
||||
(float) config('services.yandex_cloud.red_floor_rub', 1000),
|
||||
(float) config('services.yandex_cloud.amber_floor_rub', 5000),
|
||||
],
|
||||
'supplier' => [
|
||||
(float) config('services.supplier.red_floor_rub', 5000),
|
||||
(float) config('services.supplier.amber_floor_rub', 15000),
|
||||
],
|
||||
default => [0.0, 0.0],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AutopodborCompetitor extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'search_run_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_federal',
|
||||
'relevance_pct',
|
||||
'origin',
|
||||
'site_url',
|
||||
'directory_urls',
|
||||
'provenance',
|
||||
'dedup_key',
|
||||
'study_run_id',
|
||||
'studied_at',
|
||||
'box',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_federal' => 'bool',
|
||||
'directory_urls' => 'array',
|
||||
'provenance' => 'array',
|
||||
'studied_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function sources(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutopodborSource::class, 'competitor_id');
|
||||
}
|
||||
|
||||
public function searchRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutopodborRun::class, 'search_run_id');
|
||||
}
|
||||
|
||||
public function studyRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutopodborRun::class, 'study_run_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AutopodborRun extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'kind',
|
||||
'status',
|
||||
'region_code',
|
||||
'params',
|
||||
'competitor_id',
|
||||
'price_rub_charged',
|
||||
'balance_transaction_id',
|
||||
'error_code',
|
||||
'started_at',
|
||||
'finished_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'params' => 'array',
|
||||
'price_rub_charged' => 'decimal:2',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function competitors(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutopodborCompetitor::class, 'search_run_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AutopodborSource extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'competitor_id',
|
||||
'study_run_id',
|
||||
'signal_type',
|
||||
'identifier',
|
||||
'phone_kind',
|
||||
'phone_type',
|
||||
'provenance_url',
|
||||
'provenance_label',
|
||||
'dedup_key',
|
||||
'created_project_id',
|
||||
'box',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function competitor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id');
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class, 'created_project_id');
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ class BalanceTransaction extends Model
|
||||
|
||||
public const TYPE_MIGRATION = 'migration';
|
||||
|
||||
public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Autopodbor\Agent\CompetitorAgent;
|
||||
use App\Services\Autopodbor\Agent\FakeCompetitorAgent;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AutopodborServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// v1: заглушка. Реальный движок биндится здесь, когда будет готов.
|
||||
$this->app->bind(CompetitorAgent::class, FakeCompetitorAgent::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\{FindCompetitorsRequest, FindCompetitorsResult, StudyCompetitorRequest, StudyCompetitorResult, ResolveByNameRequest, ResolveByNameResult};
|
||||
|
||||
interface CompetitorAgent
|
||||
{
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult;
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult;
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class FindCompetitorsRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $regionCode,
|
||||
public readonly array $examples,
|
||||
public readonly array $aboutSelf,
|
||||
public readonly bool $includeFederal,
|
||||
public readonly int $maxCompetitors,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class FindCompetitorsResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{name:string,description?:?string,is_federal?:bool,relevance_pct?:?int,site_url?:?string,directory_urls?:array,provenance?:array}> $competitors
|
||||
*/
|
||||
public function __construct(public readonly array $competitors) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class ResolveByNameRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly int $regionCode,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class ResolveByNameResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{name:string,description?:?string,site_url?:?string,directory_urls?:array,provenance?:array}> $candidates
|
||||
*/
|
||||
public function __construct(public readonly array $candidates) {}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class StudyCompetitorRequest
|
||||
{
|
||||
/**
|
||||
* @param array{name:string,site_url?:?string,directory_urls?:array} $competitor
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $competitor,
|
||||
public readonly int $regionCode,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent\Dto;
|
||||
|
||||
final class StudyCompetitorResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array{signal_type:string,identifier:string,phone_kind?:?string,phone_type?:?string,provenance_url?:?string,provenance_label?:?string}> $sources
|
||||
*/
|
||||
public function __construct(public readonly array $sources) {}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Autopodbor\Agent;
|
||||
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\ResolveByNameResult;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorRequest;
|
||||
use App\Services\Autopodbor\Agent\Dto\StudyCompetitorResult;
|
||||
|
||||
final class FakeCompetitorAgent implements CompetitorAgent
|
||||
{
|
||||
public function findCompetitors(FindCompetitorsRequest $r): FindCompetitorsResult
|
||||
{
|
||||
return new FindCompetitorsResult([
|
||||
['name' => 'Окна Комфорт', 'description' => 'Пластиковые окна и остекление балконов под ключ.', 'is_federal' => false, 'relevance_pct' => 100, 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Пластика Окон', 'description' => 'Окна ПВХ, лоджии, входные группы.', 'is_federal' => false, 'relevance_pct' => 96, 'site_url' => 'plastika-okon-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/2'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Фабрика Окон', 'description' => 'Федеральная сеть окон ПВХ, филиал в регионе.', 'is_federal' => true, 'relevance_pct' => 84, 'site_url' => 'fabrika-okon.ru', 'directory_urls' => ['https://2gis.ru/firm/3'], 'provenance' => ['via' => 'similar-pages']],
|
||||
['name' => 'Балкон-Сервис 16', 'description' => 'Остекление балконов; окна частично.', 'is_federal' => false, 'relevance_pct' => 61, 'site_url' => null, 'directory_urls' => ['https://yandex.ru/maps/4', 'https://2gis.ru/firm/4'], 'provenance' => ['via' => 'similar-pages']],
|
||||
]);
|
||||
}
|
||||
|
||||
public function studyCompetitor(StudyCompetitorRequest $r): StudyCompetitorResult
|
||||
{
|
||||
return new StudyCompetitorResult([
|
||||
['signal_type' => 'site', 'identifier' => 'okna-komfort-kzn.ru', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
|
||||
['signal_type' => 'site', 'identifier' => 'okna-komfort.pro', 'phone_kind' => null, 'phone_type' => null, 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — сайт в контактах'],
|
||||
['signal_type' => 'call', 'identifier' => '78432001122', 'phone_kind' => 'real', 'phone_type' => 'city', 'provenance_url' => 'https://2gis.ru/firm/1', 'provenance_label' => '2ГИС — карточка компании'],
|
||||
['signal_type' => 'call', 'identifier' => '78432009988', 'phone_kind' => 'substitute', 'phone_type' => 'city', 'provenance_url' => 'https://okna-komfort-kzn.ru', 'provenance_label' => 'номер в шапке (коллтрекинг)'],
|
||||
['signal_type' => 'call', 'identifier' => '79172001122', 'phone_kind' => 'real', 'phone_type' => 'mobile', 'provenance_url' => 'https://yandex.ru/maps/1', 'provenance_label' => 'Яндекс.Карты — карточка компании'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveByName(ResolveByNameRequest $r): ResolveByNameResult
|
||||
{
|
||||
return new ResolveByNameResult([
|
||||
['name' => $r->name, 'description' => 'Найдено по названию (заглушка).', 'site_url' => 'okna-komfort-kzn.ru', 'directory_urls' => ['https://2gis.ru/firm/1'], 'provenance' => ['via' => 'name-search']],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сервис списания за прогон автоподбора конкурентов.
|
||||
*
|
||||
* Контракт:
|
||||
* - Списание только при готовом результате (by-success).
|
||||
* - Атомарное: весь flow в одной DB-транзакции.
|
||||
* - Идемпотентное: повторный вызов с тем же run не изменяет баланс
|
||||
* (guard по balance_transaction_id).
|
||||
* - bcmath: никаких float-арифметик.
|
||||
*
|
||||
* @throws InsufficientBalanceException если balance_rub < priceRub.
|
||||
* До throw баланс и транзакции не меняются.
|
||||
*/
|
||||
final class AutopodborChargeService
|
||||
{
|
||||
public function chargeForRun(AutopodborRun $run, string $priceRub): void
|
||||
{
|
||||
DB::transaction(function () use ($run, $priceRub): void {
|
||||
// Блокируем run первым — guard идемпотентности
|
||||
/** @var AutopodborRun $locked */
|
||||
$locked = AutopodborRun::whereKey($run->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
if ($locked->balance_transaction_id !== null) {
|
||||
// Уже списано — идемпотентный возврат без второго списания
|
||||
return;
|
||||
}
|
||||
|
||||
if (bccomp($priceRub, '0', 2) === 0) {
|
||||
// Бесплатный прогон — без ledger-строки; фиксируем факт нулевой стоимости.
|
||||
if ($locked->price_rub_charged === null) {
|
||||
$locked->price_rub_charged = '0.00';
|
||||
$locked->save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Блокируем tenant для атомарного изменения баланса
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::whereKey($locked->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
// bcmath: сравниваем с точностью 2 знака
|
||||
if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: (int) bcmul($priceRub, '100', 0),
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
$newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2);
|
||||
|
||||
// Обновляем баланс через DB::table (как в LedgerService) — надёжнее при decimal
|
||||
DB::table('tenants')
|
||||
->where('id', $tenant->id)
|
||||
->update(['balance_rub' => $newBalance]);
|
||||
|
||||
// Записываем транзакцию
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_AUTOPODBOR_CHARGE,
|
||||
'amount_rub' => '-'.$priceRub,
|
||||
'amount_leads' => null,
|
||||
'balance_rub_after' => $newBalance,
|
||||
'balance_leads_after' => null,
|
||||
'related_type' => AutopodborRun::class,
|
||||
'related_id' => $locked->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Фиксируем на run идемпотентный маркер
|
||||
$locked->balance_transaction_id = $tx->id;
|
||||
$locked->price_rub_charged = $priceRub;
|
||||
$locked->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
final class AutopodborDedup
|
||||
{
|
||||
public function __construct(private AutopodborNormalizer $norm) {}
|
||||
|
||||
/**
|
||||
* Ищет существующий проект арендатора с тем же типом и нормализованным идентификатором.
|
||||
* Возвращает id найденного проекта или null.
|
||||
*/
|
||||
public function existingProjectId(int $tenantId, string $signalType, string $identifier): ?int
|
||||
{
|
||||
$needle = $signalType === 'call'
|
||||
? $this->norm->phone($identifier)
|
||||
: $this->norm->domainHead($identifier);
|
||||
|
||||
return Project::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('signal_type', $signalType)
|
||||
->where('signal_identifier', $needle)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедупликация источников внутри переданного списка по нормализованному ключу.
|
||||
* Возвращает уникальные элементы с добавленным полем dedup_key.
|
||||
*
|
||||
* @param array<int, array{signal_type: string, identifier: string}> $sources
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function dedupSources(array $sources): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($sources as $s) {
|
||||
$key = $this->norm->sourceKey($s['signal_type'], $s['identifier']);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$s['dedup_key'] = $key;
|
||||
$out[] = $s;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Дедупликация конкурентов внутри переданного списка по нормализованному ключу.
|
||||
* Возвращает уникальные элементы с добавленным полем dedup_key.
|
||||
*
|
||||
* @param array<int, array{name: string, site_url?: string|null}> $competitors
|
||||
* @return array<int, array>
|
||||
*/
|
||||
public function dedupCompetitors(array $competitors): array
|
||||
{
|
||||
$seen = [];
|
||||
$out = [];
|
||||
|
||||
foreach ($competitors as $c) {
|
||||
$key = $this->norm->competitorKey($c['name'], $c['site_url'] ?? null);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$c['dedup_key'] = $key;
|
||||
$out[] = $c;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Support\PhoneNormalizer;
|
||||
|
||||
/**
|
||||
* Нормализует домены и телефоны для дедупликации конкурентов и источников.
|
||||
*/
|
||||
final class AutopodborNormalizer
|
||||
{
|
||||
/**
|
||||
* Возвращает «голову» домена: без схемы, www, пути, порта, нижний регистр.
|
||||
* Примеры:
|
||||
* https://www.Okna-Komfort.RU/contacts → okna-komfort.ru
|
||||
* http://site.ru:8080/path?x=1 → site.ru
|
||||
*/
|
||||
public function domainHead(string $raw): string
|
||||
{
|
||||
$s = trim(mb_strtolower($raw));
|
||||
// Убираем схему (http://, https://, ftp:// и т.п.)
|
||||
$s = preg_replace('#^[a-z]+://#', '', $s);
|
||||
// Убираем www.
|
||||
$s = preg_replace('#^www\.#', '', $s);
|
||||
// Берём только host часть (до первого /)
|
||||
$s = explode('/', $s)[0];
|
||||
// Убираем query string если вдруг осталась
|
||||
$s = explode('?', $s)[0];
|
||||
// Убираем порт
|
||||
$s = explode(':', $s)[0];
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует телефон к виду 7xxxxxxxxxx (11 цифр, без плюса).
|
||||
* Использует существующий PhoneNormalizer::normalize, который возвращает +7XXXXXXXXXX.
|
||||
*/
|
||||
public function phone(string $raw): string
|
||||
{
|
||||
$normalized = PhoneNormalizer::normalize($raw);
|
||||
|
||||
if ($normalized === null) {
|
||||
// Fallback: оставить только цифры и привести к 7xxxxxxxxxx
|
||||
$digits = preg_replace('/\D+/', '', $raw) ?? '';
|
||||
if (strlen($digits) === 11 && ($digits[0] === '8' || $digits[0] === '7')) {
|
||||
return '7' . substr($digits, 1);
|
||||
}
|
||||
if (strlen($digits) === 10) {
|
||||
return '7' . $digits;
|
||||
}
|
||||
return $digits;
|
||||
}
|
||||
|
||||
// PhoneNormalizer возвращает +7XXXXXXXXXX — срезаем ведущий '+'
|
||||
return ltrim($normalized, '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит dedup-ключ для источника (сайт или звонок).
|
||||
* Формат: «type:нормализованный_идентификатор»
|
||||
*/
|
||||
public function sourceKey(string $type, string $identifier): string
|
||||
{
|
||||
$id = $type === 'call'
|
||||
? $this->phone($identifier)
|
||||
: $this->domainHead($identifier);
|
||||
|
||||
return $type . ':' . $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Срезает хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
|
||||
* Если значка нет — строка возвращается без изменений.
|
||||
* Примеры:
|
||||
* 'Окна Комфорт ✓' → 'Окна Комфорт'
|
||||
* 'Окна Комфорт 🎭' → 'Окна Комфорт'
|
||||
* 'Окна Комфорт' → 'Окна Комфорт'
|
||||
* 'Балкон-Сервис 16' → 'Балкон-Сервис 16'
|
||||
*/
|
||||
public function stripBadge(string $name): string
|
||||
{
|
||||
// Срезаем ровно один хвостовой значок (✓ или 🎭) вместе с пробелами перед ним.
|
||||
// Используем mb-безопасный regex с флагом u (эмодзи 🎭 — 4-байтный).
|
||||
return preg_replace('/\s*(?:\x{2713}|\x{1F3AD})\s*$/u', '', $name) ?? $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит dedup-ключ для конкурента.
|
||||
* Если есть сайт — «site:домен», иначе «name:имя_в_нижнем_регистре».
|
||||
*/
|
||||
public function competitorKey(string $name, ?string $siteUrl): string
|
||||
{
|
||||
if ($siteUrl !== null) {
|
||||
return 'site:' . $this->domainHead($siteUrl);
|
||||
}
|
||||
|
||||
// Нижний регистр + схлопываем пробелы
|
||||
$normalized = preg_replace('#\s+#u', ' ', trim(mb_strtolower($name)));
|
||||
|
||||
return 'name:' . $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
|
||||
final class AutopodborProjectCreator
|
||||
{
|
||||
public function __construct(private ProjectService $projects) {}
|
||||
|
||||
/**
|
||||
* @param int[] $sourceIds
|
||||
* @param array{regions:int[],daily_limit_target:int,delivery_days_mask:int} $common
|
||||
* @return Project[]
|
||||
*/
|
||||
public function createFromSources(int $tenantId, array $sourceIds, array $common, bool $launch): array
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$sources = AutopodborSource::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $sourceIds)->with('competitor')->get();
|
||||
|
||||
$created = [];
|
||||
foreach ($sources as $src) {
|
||||
$name = $this->uniqueName($tenantId, $this->displayName($src));
|
||||
$project = $this->projects->create($tenant, [
|
||||
'name' => $name,
|
||||
'signal_type' => $src->signal_type,
|
||||
'signal_identifier' => $src->identifier,
|
||||
'daily_limit_target' => $common['daily_limit_target'],
|
||||
'regions' => $common['regions'],
|
||||
'delivery_days_mask' => $common['delivery_days_mask'],
|
||||
]);
|
||||
if (! $launch) {
|
||||
$project->update(['is_active' => false, 'paused_at' => now()]);
|
||||
$project = $project->fresh();
|
||||
}
|
||||
$src->update(['created_project_id' => $project->id]);
|
||||
$created[] = $project;
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function displayName(AutopodborSource $s): string
|
||||
{
|
||||
$n = $s->competitor->name;
|
||||
if ($s->signal_type === 'call' && $s->phone_kind === 'real') {
|
||||
return $n.' ✓';
|
||||
}
|
||||
if ($s->signal_type === 'call' && $s->phone_kind === 'substitute') {
|
||||
return $n.' 🎭';
|
||||
}
|
||||
|
||||
return $n;
|
||||
}
|
||||
|
||||
private function uniqueName(int $tenantId, string $base): string
|
||||
{
|
||||
$name = $base;
|
||||
$i = 1;
|
||||
while (Project::where('tenant_id', $tenantId)->where('name', $name)->exists()) {
|
||||
$i++;
|
||||
$name = $base.' '.$i;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Autopodbor;
|
||||
|
||||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||||
use App\Exceptions\Autopodbor\RunInFlightException;
|
||||
use App\Jobs\Autopodbor\RunAutopodborSearchJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborStudyJob;
|
||||
use App\Jobs\Autopodbor\RunAutopodborResolveJob;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\SystemSettings;
|
||||
|
||||
final class AutopodborRunService
|
||||
{
|
||||
public function __construct(
|
||||
private AutopodborNormalizer $normalizer = new AutopodborNormalizer(),
|
||||
) {}
|
||||
|
||||
private function assertNoInFlight(int $tenantId, string $kind): void
|
||||
{
|
||||
$exists = AutopodborRun::where('tenant_id', $tenantId)
|
||||
->where('kind', $kind)
|
||||
->whereIn('status', ['queued', 'running'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new RunInFlightException();
|
||||
}
|
||||
}
|
||||
|
||||
private function priceGate(int $tenantId, string $key): string
|
||||
{
|
||||
$price = (string) (SystemSettings::get($key) ?? '0');
|
||||
$balance = (string) Tenant::whereKey($tenantId)->value('balance_rub');
|
||||
|
||||
if (bccomp($balance, $price, 2) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: (int) bcmul($price, '100', 0),
|
||||
balanceRub: $balance,
|
||||
);
|
||||
}
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
public function startSearch(
|
||||
int $tenantId,
|
||||
int $regionCode,
|
||||
array $examples,
|
||||
array $aboutSelf,
|
||||
bool $includeFederal,
|
||||
): AutopodborRun {
|
||||
$this->assertNoInFlight($tenantId, 'search');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_search_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'search',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'params' => [
|
||||
'examples' => $examples,
|
||||
'about_self' => $aboutSelf,
|
||||
'include_federal' => $includeFederal,
|
||||
],
|
||||
]);
|
||||
|
||||
RunAutopodborSearchJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function startStudy(int $tenantId, int $competitorId): AutopodborRun
|
||||
{
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenantId)->findOrFail($competitorId);
|
||||
|
||||
if ($comp->studied_at !== null) {
|
||||
return $comp->studyRun;
|
||||
}
|
||||
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $comp->searchRun?->region_code,
|
||||
'competitor_id' => $comp->id,
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручное изучение: создаём конкурента origin='manual' и сразу ставим study-прогон
|
||||
* с ЯВНЫМ регионом (у ручного конкурента нет searchRun, откуда взять регион).
|
||||
*
|
||||
* @param array{name:string, site_url:?string, directory_urls:array} $competitorData
|
||||
*/
|
||||
public function startManualStudy(int $tenantId, array $competitorData, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'study');
|
||||
$this->priceGate($tenantId, 'autopodbor_price_study_rub');
|
||||
|
||||
$comp = AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'search_run_id' => null,
|
||||
'name' => $competitorData['name'],
|
||||
'origin' => 'manual',
|
||||
'relevance_pct' => null,
|
||||
'site_url' => $competitorData['site_url'] ?? null,
|
||||
'directory_urls' => $competitorData['directory_urls'] ?? [],
|
||||
'dedup_key' => $this->normalizer->competitorKey($competitorData['name'], $competitorData['site_url'] ?? null),
|
||||
]);
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'study',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'competitor_id' => $comp->id,
|
||||
'params' => [],
|
||||
]);
|
||||
|
||||
RunAutopodborStudyJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function startResolve(int $tenantId, string $name, int $regionCode): AutopodborRun
|
||||
{
|
||||
$this->assertNoInFlight($tenantId, 'resolve');
|
||||
// resolve бесплатный — без priceGate
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'kind' => 'resolve',
|
||||
'status' => 'queued',
|
||||
'region_code' => $regionCode,
|
||||
'params' => ['name' => $name],
|
||||
]);
|
||||
|
||||
RunAutopodborResolveJob::dispatch($run->id);
|
||||
|
||||
return $run;
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Dashboard;
|
||||
|
||||
/**
|
||||
* Чистая логика светофора балансов внешних сервисов: «хватит на N дней» + цвет.
|
||||
* Без БД/сети — unit-тестируема. Светофор по ДВУМ правилам (решение владельца 28.06):
|
||||
* 🔴 баланс < red_floor ИЛИ дней_осталось < 3
|
||||
* 🟡 баланс < amber_floor ИЛИ дней_осталось < 7
|
||||
* 🟢 иначе
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
|
||||
*/
|
||||
class BalanceHealth
|
||||
{
|
||||
/**
|
||||
* @return array{days_left:?int,light:string}
|
||||
*/
|
||||
public static function evaluate(
|
||||
float $balance,
|
||||
?float $dailySpend,
|
||||
float $redFloor,
|
||||
float $amberFloor,
|
||||
): array {
|
||||
// Отрицательный/нулевой баланс → денег уже нет: 0 дней (не отрицательное «−1 дн.»).
|
||||
$days = ($dailySpend !== null && $dailySpend > 0)
|
||||
? max(0, (int) floor($balance / $dailySpend))
|
||||
: null;
|
||||
|
||||
$light = 'green';
|
||||
if ($balance < $amberFloor || ($days !== null && $days < 7)) {
|
||||
$light = 'amber';
|
||||
}
|
||||
if ($balance < $redFloor || ($days !== null && $days < 3)) {
|
||||
$light = 'red';
|
||||
}
|
||||
|
||||
return ['days_left' => $days, 'light' => $light];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
/**
|
||||
* Переходник на один внешний платный сервис: читает его баланс.
|
||||
* Изоляция: fetch() НЕ бросает — любую ошибку (сеть/доступ/парсинг) заворачивает
|
||||
* в BalanceReading::fail(), чтобы падение одного сервиса не роняло плитку.
|
||||
*/
|
||||
interface BalanceProvider
|
||||
{
|
||||
/** dadata | supplier | yandex_cloud */
|
||||
public function serviceKey(): string;
|
||||
|
||||
public function fetch(): BalanceReading;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Снимок баланса одного внешнего сервиса. Иммутабельный DTO — результат провайдера.
|
||||
* Провайдер НЕ бросает исключения наружу: ошибку заворачивает в self::fail().
|
||||
*/
|
||||
final class BalanceReading
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $serviceKey,
|
||||
public readonly ?float $balance,
|
||||
public readonly string $currency,
|
||||
public readonly ?float $dailySpend,
|
||||
public readonly bool $ok,
|
||||
public readonly ?string $error,
|
||||
public readonly Carbon $checkedAt,
|
||||
) {}
|
||||
|
||||
public static function ok(string $key, float $balance, string $currency, ?float $dailySpend): self
|
||||
{
|
||||
return new self($key, $balance, $currency, $dailySpend, true, null, now());
|
||||
}
|
||||
|
||||
public static function fail(string $key, string $error): self
|
||||
{
|
||||
return new self($key, null, 'RUB', null, false, mb_substr($error, 0, 500), now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Баланс профиля DaData (резолв региона/ИНН лида). API: GET profile/balance
|
||||
* с заголовком Authorization: Token <api_key>.
|
||||
*/
|
||||
class DadataBalanceProvider implements BalanceProvider
|
||||
{
|
||||
public function serviceKey(): string
|
||||
{
|
||||
return 'dadata';
|
||||
}
|
||||
|
||||
public function fetch(): BalanceReading
|
||||
{
|
||||
try {
|
||||
$key = (string) config('services.dadata.api_key');
|
||||
if ($key === '') {
|
||||
return BalanceReading::fail('dadata', 'DaData api_key не задан');
|
||||
}
|
||||
// Эндпоинт profile/balance требует ОБА ключа: Authorization: Token <api_key>
|
||||
// И X-Secret: <secret> (иначе HTTP 401). secret — тот же, что для cleaner API.
|
||||
$headers = ['Authorization' => 'Token '.$key, 'Accept' => 'application/json'];
|
||||
$secret = (string) config('services.dadata.secret');
|
||||
if ($secret !== '') {
|
||||
$headers['X-Secret'] = $secret;
|
||||
}
|
||||
$resp = Http::timeout(10)
|
||||
->withHeaders($headers)
|
||||
->get((string) config('services.dadata.balance_url'));
|
||||
if (! $resp->ok()) {
|
||||
return BalanceReading::fail('dadata', 'HTTP '.$resp->status());
|
||||
}
|
||||
$balance = (float) ($resp->json('balance') ?? 0);
|
||||
|
||||
return BalanceReading::ok('dadata', $balance, 'RUB', $this->dailySpend());
|
||||
} catch (\Throwable $e) {
|
||||
return BalanceReading::fail('dadata', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Оценка расхода/день: вызовы резолва за 7д × стоимость вызова.
|
||||
* Best-effort — любая ошибка подсчёта НЕ должна ронять чтение баланса.
|
||||
*/
|
||||
private function dailySpend(): ?float
|
||||
{
|
||||
try {
|
||||
$costRub = ((int) config('services.dadata.call_cost_kopecks', 60)) / 100;
|
||||
$calls7d = DB::table('supplier_leads')
|
||||
->where('region_source', 'dadata')
|
||||
->where('received_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
if ($calls7d === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($calls7d / 7) * $costRub, 2);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
use App\Services\Supplier\PlaywrightBridge;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Баланс кабинета поставщика лидов (crm.bp-gr.ru). У кабинета нет JSON-эндпоинта
|
||||
* баланса → читаем со страницы через headless Playwright (тот же логин-флоу, что
|
||||
* RefreshSupplierSessionJob). Селектор/URL баланса калибруются разведкой на проде
|
||||
* (см. план Task 6 Step 1); до калибровки скрипт вернёт exit 2 → fail «баланс не найден».
|
||||
*/
|
||||
class SupplierBalanceProvider implements BalanceProvider
|
||||
{
|
||||
public function __construct(private readonly PlaywrightBridge $bridge) {}
|
||||
|
||||
public function serviceKey(): string
|
||||
{
|
||||
return 'supplier';
|
||||
}
|
||||
|
||||
public function fetch(): BalanceReading
|
||||
{
|
||||
try {
|
||||
$login = (string) config('services.supplier.login');
|
||||
$password = (string) config('services.supplier.password');
|
||||
if ($login === '' || $password === '') {
|
||||
return BalanceReading::fail('supplier', 'Доступ к кабинету поставщика не настроен');
|
||||
}
|
||||
$out = $this->bridge->run([
|
||||
'script' => 'supplier-balance.js',
|
||||
'login' => $login,
|
||||
'password' => $password,
|
||||
'url' => (string) config('services.supplier.portal_url'),
|
||||
]);
|
||||
// У кабинета не деньги, а остаток НОМЕРОВ («Баланс ГЦК»). Деньги = номера × цена.
|
||||
if (! isset($out['numbers']) || ! is_numeric($out['numbers'])) {
|
||||
return BalanceReading::fail('supplier', 'Остаток номеров не найден в кабинете');
|
||||
}
|
||||
$numbers = (int) $out['numbers'];
|
||||
$price = (float) config('services.supplier.number_price_rub', 20);
|
||||
|
||||
return BalanceReading::ok(
|
||||
'supplier',
|
||||
$numbers * $price,
|
||||
'RUB',
|
||||
$this->dailySpend(),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return BalanceReading::fail('supplier', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Оценка расхода/день: лиды за 7д ÷ 7 × средняя цена лида (из конфига).
|
||||
* Best-effort — нет цены или ошибка подсчёта → null (светофор только по порогам).
|
||||
*/
|
||||
private function dailySpend(): ?float
|
||||
{
|
||||
try {
|
||||
$price = (float) config('services.supplier.avg_lead_price_rub', 0);
|
||||
if ($price <= 0) {
|
||||
return null;
|
||||
}
|
||||
$leads7d = DB::table('supplier_leads')
|
||||
->where('received_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
if ($leads7d === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($leads7d / 7) * $price, 2);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\External;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Баланс биллинг-аккаунта Yandex Cloud (серверы + Managed PG).
|
||||
* Поток: OAuth-токен → IAM-токен (iam/v1/tokens) → billing/v1/billingAccounts/{id}.
|
||||
* Расход/день — оценка из конфига (месячный ÷ 30), уточняется по факту.
|
||||
*/
|
||||
class YandexCloudBalanceProvider implements BalanceProvider
|
||||
{
|
||||
public function serviceKey(): string
|
||||
{
|
||||
return 'yandex_cloud';
|
||||
}
|
||||
|
||||
public function fetch(): BalanceReading
|
||||
{
|
||||
try {
|
||||
$oauth = (string) config('services.yandex_cloud.oauth_token');
|
||||
$acc = (string) config('services.yandex_cloud.billing_account_id');
|
||||
if ($oauth === '' || $acc === '') {
|
||||
return BalanceReading::fail('yandex_cloud', 'YC доступ не настроен');
|
||||
}
|
||||
$iam = Http::timeout(10)->post((string) config('services.yandex_cloud.iam_url'), [
|
||||
'yandexPassportOauthToken' => $oauth,
|
||||
]);
|
||||
if (! $iam->ok() || ! $iam->json('iamToken')) {
|
||||
return BalanceReading::fail('yandex_cloud', 'IAM exchange: HTTP '.$iam->status());
|
||||
}
|
||||
$resp = Http::timeout(10)
|
||||
->withToken((string) $iam->json('iamToken'))
|
||||
->get((string) config('services.yandex_cloud.billing_url').'/'.$acc);
|
||||
if (! $resp->ok()) {
|
||||
return BalanceReading::fail('yandex_cloud', 'Billing: HTTP '.$resp->status());
|
||||
}
|
||||
$balance = (float) ($resp->json('balance') ?? 0);
|
||||
$currency = (string) ($resp->json('currency') ?? 'RUB');
|
||||
$spend = ((float) config('services.yandex_cloud.daily_spend_rub')) ?: null;
|
||||
|
||||
return BalanceReading::ok('yandex_cloud', $balance, $currency, $spend);
|
||||
} catch (\Throwable $e) {
|
||||
return BalanceReading::fail('yandex_cloud', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? [];
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\AutopodborServiceProvider::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'),
|
||||
|
||||
@@ -49,6 +49,14 @@ return [
|
||||
'password' => env('SUPPLIER_PASSWORD'),
|
||||
'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'),
|
||||
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
|
||||
// Плитка балансов (28.06): у кабинета поставщика НЕ денежный баланс, а остаток
|
||||
// НОМЕРОВ («Баланс ГЦК» в выпадашке). Деньги = номера × number_price_rub (20 ₽/шт,
|
||||
// подтверждено владельцем 28.06). avg_lead_price_rub=0 → расход/день неизвестен.
|
||||
'number_price_rub' => (float) env('SUPPLIER_NUMBER_PRICE_RUB', 20),
|
||||
'avg_lead_price_rub' => (float) env('SUPPLIER_AVG_LEAD_PRICE_RUB', 0),
|
||||
'red_floor_rub' => (int) env('SUPPLIER_RED_FLOOR_RUB', 5000),
|
||||
'amber_floor_rub' => (int) env('SUPPLIER_AMBER_FLOOR_RUB', 15000),
|
||||
'topup_url' => env('SUPPLIER_TOPUP_URL', env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru')),
|
||||
],
|
||||
|
||||
// DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
|
||||
@@ -65,6 +73,26 @@ return [
|
||||
// G1/SP2: подтяжка организации по ИНН (suggestions findById/party). Тот же api_key
|
||||
// (Token), secret не нужен. Default false → NullPartyLookup (dev/тесты не ходят в сеть).
|
||||
'party_enabled' => filter_var(env('DADATA_PARTY_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
// Плитка балансов (28.06): чтение баланса профиля + пороги светофора + ссылка пополнения.
|
||||
'balance_url' => env('DADATA_BALANCE_URL', 'https://dadata.ru/api/v2/profile/balance'),
|
||||
'red_floor_rub' => (int) env('DADATA_RED_FLOOR_RUB', 500),
|
||||
'amber_floor_rub' => (int) env('DADATA_AMBER_FLOOR_RUB', 2000),
|
||||
'topup_url' => env('DADATA_TOPUP_URL', 'https://dadata.ru/profile/#billing'),
|
||||
],
|
||||
|
||||
// Плитка балансов (28.06): Yandex Cloud биллинг (серверы + Managed PG ~18к/мес).
|
||||
// OAuth владельца (interim) → IAM-токен → billing API. SA billing-reader создан,
|
||||
// миграция на него — follow-up (только источник токена сменится). console_billing_url
|
||||
// + billing_account_id строят прямую ссылку «Пополнить» в дашборде.
|
||||
'yandex_cloud' => [
|
||||
'oauth_token' => env('YC_OAUTH_TOKEN'),
|
||||
'billing_account_id' => env('YC_BILLING_ACCOUNT_ID'),
|
||||
'iam_url' => env('YC_IAM_URL', 'https://iam.api.cloud.yandex.net/iam/v1/tokens'),
|
||||
'billing_url' => env('YC_BILLING_URL', 'https://billing.api.cloud.yandex.net/billing/v1/billingAccounts'),
|
||||
'console_billing_url' => env('YC_CONSOLE_BILLING_URL', 'https://console.yandex.cloud/billing/accounts'),
|
||||
'daily_spend_rub' => (int) env('YC_DAILY_SPEND_RUB', 600), // оценка ~18к/мес; откалибровать
|
||||
'red_floor_rub' => (int) env('YC_RED_FLOOR_RUB', 1000),
|
||||
'amber_floor_rub' => (int) env('YC_AMBER_FLOOR_RUB', 5000),
|
||||
],
|
||||
|
||||
// G7-A: клиентская «Помощь».
|
||||
|
||||
@@ -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,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('autopodbor_runs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->string('kind', 16); // search | study | resolve
|
||||
$table->string('status', 16)->default('queued'); // queued|running|done|empty|failed
|
||||
$table->smallInteger('region_code')->nullable();
|
||||
$table->jsonb('params')->default(DB::raw("'{}'::jsonb"));
|
||||
$table->unsignedBigInteger('competitor_id')->nullable();
|
||||
$table->decimal('price_rub_charged', 12, 2)->nullable();
|
||||
$table->unsignedBigInteger('balance_transaction_id')->nullable();
|
||||
$table->string('error_code', 64)->nullable();
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
$table->timestampTz('started_at')->nullable();
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'kind', 'status']);
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE autopodbor_runs ENABLE ROW LEVEL SECURITY');
|
||||
DB::statement('ALTER TABLE autopodbor_runs FORCE ROW LEVEL SECURITY');
|
||||
DB::statement("CREATE POLICY tenant_isolation ON autopodbor_runs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('autopodbor_runs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('autopodbor_competitors', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->unsignedBigInteger('search_run_id')->nullable();
|
||||
$table->string('name', 255);
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_federal')->default(false);
|
||||
$table->smallInteger('relevance_pct')->nullable();
|
||||
$table->string('origin', 16)->default('auto'); // auto|manual|resolve
|
||||
$table->string('site_url', 255)->nullable();
|
||||
$table->jsonb('directory_urls')->default(DB::raw("'[]'::jsonb"));
|
||||
$table->jsonb('provenance')->default(DB::raw("'{}'::jsonb"));
|
||||
$table->string('dedup_key', 255);
|
||||
$table->unsignedBigInteger('study_run_id')->nullable();
|
||||
$table->timestampTz('studied_at')->nullable();
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
|
||||
$table->foreign('search_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete();
|
||||
$table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->nullOnDelete();
|
||||
$table->index(['tenant_id', 'search_run_id']);
|
||||
$table->unique(['tenant_id', 'search_run_id', 'dedup_key'], 'autopodbor_competitor_dedup');
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE autopodbor_competitors ENABLE ROW LEVEL SECURITY');
|
||||
DB::statement('ALTER TABLE autopodbor_competitors FORCE ROW LEVEL SECURITY');
|
||||
DB::statement("CREATE POLICY tenant_isolation ON autopodbor_competitors USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('autopodbor_competitors');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('autopodbor_sources', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedBigInteger('tenant_id');
|
||||
$table->unsignedBigInteger('competitor_id');
|
||||
$table->unsignedBigInteger('study_run_id');
|
||||
$table->string('signal_type', 8); // site | call
|
||||
$table->string('identifier', 255); // голова домена / 7xxxxxxxxxx
|
||||
$table->string('phone_kind', 12)->nullable(); // real | substitute | null(site)
|
||||
$table->string('provenance_url', 500)->nullable();
|
||||
$table->string('provenance_label', 255)->nullable();
|
||||
$table->string('dedup_key', 255);
|
||||
$table->unsignedBigInteger('created_project_id')->nullable();
|
||||
$table->timestampTz('created_at')->useCurrent();
|
||||
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
|
||||
$table->foreign('competitor_id')->references('id')->on('autopodbor_competitors')->cascadeOnDelete();
|
||||
$table->foreign('study_run_id')->references('id')->on('autopodbor_runs')->cascadeOnDelete();
|
||||
$table->foreign('created_project_id')->references('id')->on('projects')->nullOnDelete();
|
||||
$table->unique(['competitor_id', 'dedup_key'], 'autopodbor_source_dedup');
|
||||
$table->index(['tenant_id', 'competitor_id']);
|
||||
});
|
||||
|
||||
DB::statement('ALTER TABLE autopodbor_sources ENABLE ROW LEVEL SECURITY');
|
||||
DB::statement('ALTER TABLE autopodbor_sources FORCE ROW LEVEL SECURITY');
|
||||
DB::statement("CREATE POLICY tenant_isolation ON autopodbor_sources USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('autopodbor_sources');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Сид настроек модуля «Автоподбор конкурентов» (Task 5).
|
||||
* Вставляет 4 ключа в system_settings (idempotent).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$rows = [
|
||||
[
|
||||
'key' => 'autopodbor_enabled',
|
||||
'value' => '0',
|
||||
'type' => 'bool',
|
||||
'description' => 'Автоподбор конкурентов: вкл/выкл вкладку',
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'key' => 'autopodbor_price_search_rub',
|
||||
'value' => '0',
|
||||
'type' => 'decimal',
|
||||
'description' => 'Цена подбора конкурентов (шаг 1), ₽',
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'key' => 'autopodbor_price_study_rub',
|
||||
'value' => '0',
|
||||
'type' => 'decimal',
|
||||
'description' => 'Цена изучения конкурента (шаг 2), ₽',
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'key' => 'autopodbor_max_competitors',
|
||||
'value' => '15',
|
||||
'type' => 'int',
|
||||
'description' => 'Макс. число конкурентов на выдаче шага 1',
|
||||
'updated_at' => now(),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$exists = DB::table('system_settings')->where('key', $row['key'])->exists();
|
||||
if (! $exists) {
|
||||
DB::table('system_settings')->insert($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('system_settings')->whereIn('key', [
|
||||
'autopodbor_enabled',
|
||||
'autopodbor_price_search_rub',
|
||||
'autopodbor_price_study_rub',
|
||||
'autopodbor_max_competitors',
|
||||
])->delete();
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Добавляет 'autopodbor_charge' в CHECK constraint balance_transactions_type_check.
|
||||
*
|
||||
* Фича «Автоподбор конкурентов» — списание за прогон через AutopodborChargeService.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
|
||||
);
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
|
||||
'CHECK (type IN ('.
|
||||
"'trial_bonus','topup','lead_charge','refund',".
|
||||
"'manual_adjustment','historical_import',".
|
||||
"'chargeback_writedown','chargeback_repayment',".
|
||||
"'migration',".
|
||||
"'autopodbor_charge'".
|
||||
'))'
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
|
||||
);
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
|
||||
'CHECK (type IN ('.
|
||||
"'trial_bonus','topup','lead_charge','refund',".
|
||||
"'manual_adjustment','historical_import',".
|
||||
"'chargeback_writedown','chargeback_repayment',".
|
||||
"'migration'".
|
||||
'))'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* external_service_balances — последний известный баланс внешних платных сервисов
|
||||
* (dadata / supplier / yandex_cloud) для плитки «Балансы сервисов» дашборда.
|
||||
*
|
||||
* Системная таблица (как supplier_sync_runs) — без RLS/tenant_id. Пишет ежедневная
|
||||
* RefreshExternalBalancesJob под crm_supplier_worker (BYPASSRLS), читает SaaS-admin
|
||||
* через pgsql_admin. Хранит ТОЛЬКО последнее значение на сервис (история — отдельный
|
||||
* follow-up), поэтому service_key — PRIMARY KEY (upsert по нему).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
|
||||
* План: docs/superpowers/plans/2026-06-28-external-service-balances.md (Task 1)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS external_service_balances (
|
||||
service_key VARCHAR(32) PRIMARY KEY,
|
||||
balance_amount NUMERIC(14,2),
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RUB',
|
||||
daily_spend_estimate NUMERIC(14,2),
|
||||
days_left INTEGER,
|
||||
light VARCHAR(8) NOT NULL DEFAULT 'green'
|
||||
CHECK (light IN ('green','amber','red','grey')),
|
||||
ok BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
error TEXT,
|
||||
checked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
foreach (['crm_supplier_worker'] as $role) {
|
||||
$supplier->statement(<<<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
|
||||
GRANT SELECT, INSERT, UPDATE ON external_service_balances TO {$role};
|
||||
END IF;
|
||||
END
|
||||
\$\$
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->statement('DROP TABLE IF EXISTS external_service_balances CASCADE');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* «Конкурентное поле» — два ящика (предложение / в поле) на конкурентах и источниках.
|
||||
* Approach A (спек §14.1): не плодим таблицы — добавляем пометку-состояние к существующим.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE autopodbor_competitors ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'");
|
||||
DB::statement("ALTER TABLE autopodbor_competitors ADD CONSTRAINT autopodbor_competitors_box_chk CHECK (box IN ('proposal', 'field'))");
|
||||
DB::statement('CREATE INDEX autopodbor_competitors_tenant_box_idx ON autopodbor_competitors (tenant_id, box)');
|
||||
|
||||
DB::statement("ALTER TABLE autopodbor_sources ADD COLUMN box VARCHAR(16) NOT NULL DEFAULT 'proposal'");
|
||||
DB::statement("ALTER TABLE autopodbor_sources ADD CONSTRAINT autopodbor_sources_box_chk CHECK (box IN ('proposal', 'field'))");
|
||||
DB::statement('CREATE INDEX autopodbor_sources_competitor_box_idx ON autopodbor_sources (competitor_id, box)');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS autopodbor_sources_competitor_box_idx');
|
||||
DB::statement('ALTER TABLE autopodbor_sources DROP CONSTRAINT IF EXISTS autopodbor_sources_box_chk');
|
||||
DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS box');
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS autopodbor_competitors_tenant_box_idx');
|
||||
DB::statement('ALTER TABLE autopodbor_competitors DROP CONSTRAINT IF EXISTS autopodbor_competitors_box_chk');
|
||||
DB::statement('ALTER TABLE autopodbor_competitors DROP COLUMN IF EXISTS box');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Тип номера телефона (городской/мобильный/8-800) — то, что даёт определитель (DaData).
|
||||
* Спек §14.5, вариант «и тип, и коллтрекинг»: phone_type ДОПОЛНЯЕТ phone_kind
|
||||
* (настоящий/подменный, ✓/🎭), не заменяет его. Для сайтов phone_type = NULL.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE autopodbor_sources ADD COLUMN phone_type VARCHAR(12)');
|
||||
DB::statement("ALTER TABLE autopodbor_sources ADD CONSTRAINT autopodbor_sources_phone_type_chk CHECK (phone_type IS NULL OR phone_type IN ('city', 'mobile', 'tollfree'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE autopodbor_sources DROP CONSTRAINT IF EXISTS autopodbor_sources_phone_type_chk');
|
||||
DB::statement('ALTER TABLE autopodbor_sources DROP COLUMN IF EXISTS phone_type');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Дефолтные тарифы доп. услуг «Конкурентного поля» (решение владельца 29.06, спек §14.11):
|
||||
* - подбор конкурентов (шаг 1) = 300 ₽;
|
||||
* - изучение конкурента / сбор источников (шаг 2) = 50 ₽.
|
||||
*
|
||||
* Сид Task 5 завёл ключи со значением '0'. Здесь проставляем рабочие дефолты,
|
||||
* но ТОЛЬКО если значение всё ещё '0' (не затираем то, что админ уже поправил
|
||||
* через PUT /api/admin/system-settings/{key}). Идемпотентно.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('system_settings')
|
||||
->where('key', 'autopodbor_price_search_rub')->where('value', '0')
|
||||
->update(['value' => '300', 'updated_at' => now()]);
|
||||
|
||||
DB::table('system_settings')
|
||||
->where('key', 'autopodbor_price_study_rub')->where('value', '0')
|
||||
->update(['value' => '50', 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('system_settings')
|
||||
->where('key', 'autopodbor_price_search_rub')->where('value', '300')
|
||||
->update(['value' => '0', 'updated_at' => now()]);
|
||||
|
||||
DB::table('system_settings')
|
||||
->where('key', 'autopodbor_price_study_rub')->where('value', '50')
|
||||
->update(['value' => '0', 'updated_at' => now()]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\AutopodborCompetitor;
|
||||
use App\Models\AutopodborRun;
|
||||
use App\Models\AutopodborSource;
|
||||
use App\Models\Project;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
/**
|
||||
* ДЕМО-сид для визуальной проверки «Конкурентного поля» глазами клиента (Омега).
|
||||
* НЕ для прода. Данные конкурентов — из реальных прогонов движка 28-29.06 (Красноярск,
|
||||
* займы под залог авто). Создаёт демо-тенант + логин, включает фичу, наполняет поле и
|
||||
* предложения реальными конкурентами и источниками (сайты + телефоны с типами).
|
||||
*
|
||||
* Запуск (только dev): php artisan db:seed --class=OmegaDemoFieldSeeder
|
||||
* Логин: omega-demo@liderra.local / omega12345
|
||||
*/
|
||||
class OmegaDemoFieldSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// 1) Тенант «Омега (демо)» + пользователь с известным паролем
|
||||
$tenant = Tenant::firstOrNew(['subdomain' => 'omega-demo']);
|
||||
$tenant->organization_name = 'Омега (демо поля)';
|
||||
$tenant->contact_email = 'omega-demo@liderra.local';
|
||||
$tenant->status = 'active';
|
||||
$tenant->balance_rub = '50000.00';
|
||||
$tenant->delivered_in_month = 0;
|
||||
$tenant->save();
|
||||
|
||||
$user = User::firstOrNew(['email' => 'omega-demo@liderra.local']);
|
||||
$user->tenant_id = $tenant->id;
|
||||
$user->first_name = 'Омега';
|
||||
$user->last_name = 'Демо';
|
||||
$user->password_hash = Hash::make('omega12345');
|
||||
$user->email_verified_at = now();
|
||||
$user->is_active = true;
|
||||
$user->save();
|
||||
|
||||
// 2) Включить фичу + тарифы доп.услуг
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_enabled'], ['value' => '1', 'type' => 'bool']);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_search_rub'], ['value' => '300', 'type' => 'decimal']);
|
||||
SystemSetting::updateOrCreate(['key' => 'autopodbor_price_study_rub'], ['value' => '50', 'type' => 'decimal']);
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// чистим прошлый демо-прогон (идемпотентность)
|
||||
$oldRuns = AutopodborRun::where('tenant_id', $tenant->id)->pluck('id');
|
||||
AutopodborSource::where('tenant_id', $tenant->id)->delete();
|
||||
AutopodborCompetitor::where('tenant_id', $tenant->id)->delete();
|
||||
AutopodborRun::whereIn('id', $oldRuns)->delete();
|
||||
|
||||
$run = AutopodborRun::create([
|
||||
'tenant_id' => $tenant->id, 'kind' => 'search', 'status' => 'done',
|
||||
'region_code' => 24, 'params' => ['region' => 'Красноярский край'],
|
||||
]);
|
||||
|
||||
// 3) Реальные конкуренты Омеги (прогон 28-29.06). box=field — клиент отобрал в поле.
|
||||
$gis = 'https://2gis.ru/krasnoyarsk/firm/0';
|
||||
$ya = 'https://yandex.ru/maps/org/0';
|
||||
$field = [
|
||||
['КрасЛомбард', 'kraslombard24.ru', false, 95, 'Сеть ломбардов, займы под залог авто и техники', [$gis, $ya], [
|
||||
['site', 'kraslombard24.ru', null, null],
|
||||
['call', '73912771717', 'real', 'city'],
|
||||
]],
|
||||
['Голд Авто Инвест', 'goldautoinvest.ru', false, 90, 'Займы под залог автомобилей, Красноярск', [$gis, $ya], [
|
||||
['site', 'goldautoinvest.ru', null, null],
|
||||
['call', '73912000111', 'substitute', 'city'],
|
||||
['call', '79130000222', 'real', 'mobile'],
|
||||
]],
|
||||
['Финео', 'fineo24.ru', true, 80, 'Федеральный сервис займов под ПТС', [$gis], [
|
||||
['site', 'fineo24.ru', null, null],
|
||||
['call', '78005000333', 'real', 'tollfree'],
|
||||
]],
|
||||
['Cashmotor', 'cashmotor.ru', true, 78, 'Федеральный автоломбард, залог авто', [], [
|
||||
['site', 'cashmotor.ru', null, null],
|
||||
]],
|
||||
['Локо-Банк', 'lockobank.ru', true, 72, 'Автокредиты и займы под залог авто', [$ya], [
|
||||
['site', 'lockobank.ru', null, null],
|
||||
]],
|
||||
];
|
||||
|
||||
// box=proposal — найдено движком, ещё не отобрано в поле
|
||||
$proposals = [
|
||||
['Автоломбард Экспресс', 'avtolombard-express.ru', false, 85, 'Срочные займы под залог авто, Красноярск'],
|
||||
['Caranga', 'caranga.ru', true, 70, 'Федеральный автоломбanд'],
|
||||
['Драйвзайм', 'drivezaim.ru', true, 65, 'Займы под залог ПТС онлайн'],
|
||||
['Залог24', 'zalog24h.ru', true, 60, 'Круглосуточные займы под залог'],
|
||||
['Кредди', 'creddy.ru', true, 55, 'Микрозаймы под залог авто'],
|
||||
];
|
||||
|
||||
foreach ($field as $i => [$name, $site, $federal, $rel, $desc, $dirs, $srcs]) {
|
||||
$comp = AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenant->id, 'search_run_id' => $run->id, 'study_run_id' => $run->id,
|
||||
'studied_at' => now(), 'name' => $name, 'description' => $desc, 'is_federal' => $federal,
|
||||
'relevance_pct' => $rel, 'origin' => 'auto', 'box' => 'field', 'site_url' => $site,
|
||||
'directory_urls' => $dirs, 'dedup_key' => 'site:'.$site,
|
||||
]);
|
||||
foreach ($srcs as [$type, $ident, $kind, $ptype]) {
|
||||
AutopodborSource::create([
|
||||
'tenant_id' => $tenant->id, 'competitor_id' => $comp->id, 'study_run_id' => $run->id,
|
||||
'signal_type' => $type, 'identifier' => $ident, 'phone_kind' => $kind, 'phone_type' => $ptype,
|
||||
'box' => 'field', 'provenance_label' => $type === 'site' ? 'сайт компании' : 'карточка в 2ГИС',
|
||||
'dedup_key' => $type.':'.$ident,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($proposals as [$name, $site, $federal, $rel, $desc]) {
|
||||
AutopodborCompetitor::create([
|
||||
'tenant_id' => $tenant->id, 'search_run_id' => $run->id,
|
||||
'name' => $name, 'description' => $desc, 'is_federal' => $federal,
|
||||
'relevance_pct' => $rel, 'origin' => 'auto', 'box' => 'proposal', 'site_url' => $site,
|
||||
'dedup_key' => 'site:'.$site,
|
||||
]);
|
||||
}
|
||||
|
||||
// 4) Демо-проекты: один «в работе», один «на паузе» — чтобы показать счётчики,
|
||||
// паузу/возобновление и смену источника на живых данных. Минимальная строка
|
||||
// (только обязательные поля), без джобов поставщика.
|
||||
$linkProject = function (string $compName, bool $active) use ($tenant) {
|
||||
$comp = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', $compName)->first();
|
||||
if (! $comp) {
|
||||
return;
|
||||
}
|
||||
$src = AutopodborSource::where('competitor_id', $comp->id)->where('signal_type', 'site')->first();
|
||||
if (! $src) {
|
||||
return;
|
||||
}
|
||||
$p = Project::firstOrNew(['tenant_id' => $tenant->id, 'name' => $compName]);
|
||||
$p->signal_identifier = $src->identifier;
|
||||
$p->signal_type = 'site';
|
||||
$p->signal_identifier = $src->identifier;
|
||||
$p->is_active = $active;
|
||||
$p->paused_at = $active ? null : now();
|
||||
$p->daily_limit_target = 20;
|
||||
$p->delivery_days_mask = 127;
|
||||
$p->save();
|
||||
$src->update(['created_project_id' => $p->id]);
|
||||
};
|
||||
$linkProject('КрасЛомбард', true); // в работе
|
||||
$linkProject('Голд Авто Инвест', false); // на паузе
|
||||
|
||||
// источники-предложения у КрасЛомбарда (результат «собрать источники» — до переноса в работу)
|
||||
$krl = AutopodborCompetitor::where('tenant_id', $tenant->id)->where('name', 'КрасЛомбард')->first();
|
||||
if ($krl) {
|
||||
foreach ([
|
||||
['site', 'kraslombard-new.ru', null, null, '2ГИС — сайт в карточке компании'],
|
||||
['call', '73912001100', 'real', 'city', '2ГИС — карточка компании'],
|
||||
] as [$type, $ident, $kind, $ptype, $prov]) {
|
||||
AutopodborSource::create([
|
||||
'tenant_id' => $tenant->id, 'competitor_id' => $krl->id, 'study_run_id' => $run->id,
|
||||
'signal_type' => $type, 'identifier' => $ident, 'phone_kind' => $kind, 'phone_type' => $ptype,
|
||||
'box' => 'proposal', 'provenance_label' => $prov, 'dedup_key' => $type.':'.$ident.':sug',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info('Омега-демо готова: '.count($field).' в поле (2 с проектами), '.count($proposals).' в предложениях. Логин omega-demo@liderra.local / omega12345');
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Generated
+46
-1
@@ -5,7 +5,8 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"playwright": "1.59.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -7787,6 +7788,50 @@
|
||||
"@vue/devtools-kit": "^7.7.9"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0.tgz",
|
||||
"integrity": "sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0.tgz",
|
||||
"integrity": "sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
|
||||
+2
-1
@@ -50,6 +50,7 @@
|
||||
"vuetify": "^3.12.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"playwright": "1.59.0"
|
||||
}
|
||||
}
|
||||
|
||||
+282
-78
@@ -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,60 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Account/UserSessionsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminDashboardBalancesTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/AdminDashboardClientsTest.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/AdminDashboardPeriodTest.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\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/AdminLeadsTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -468,6 +540,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
|
||||
@@ -567,7 +657,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 13
|
||||
count: 15
|
||||
path: tests/Feature/AdminTenantsIndexTest.php
|
||||
|
||||
-
|
||||
@@ -576,6 +666,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 +759,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 +771,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 +804,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 +846,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 +1002,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 +1044,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 +1083,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 +1128,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 +1203,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 +1290,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 +1383,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 +1515,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 +1527,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 +1545,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 +1953,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 +2064,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 +2106,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 +2427,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 +2454,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 +2502,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 +2862,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 +2874,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 +2922,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
|
||||
@@ -2892,6 +3102,18 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Exceptions/InsufficientBalanceExceptionTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Unit/External/SupplierBalanceProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Unit/External/SupplierBalanceProviderTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tiers\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -3005,21 +3227,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,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Headless Playwright чтение остатка НОМЕРОВ в кабинете поставщика crm.bp-gr.ru.
|
||||
*
|
||||
* У кабинета нет денежного баланса: в шапке выпадашка «Баланс» (js-dropdown,
|
||||
* data-toggle="dropdown") с таблицей table.balancetbl. Нужная строка —
|
||||
* «Баланс ГЦК» = количество доступных номеров (подтверждено владельцем 28.06).
|
||||
* Деньги считаются на стороне PHP: номера × number_price_rub (20 ₽/шт).
|
||||
*
|
||||
* Логин-флоу — копия refresh-session.js (Yii2 LoginForm).
|
||||
*
|
||||
* Input (JSON через stdin): {login, password, url}
|
||||
* Output (JSON через stdout): {numbers: <int>}
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — success (число номеров найдено)
|
||||
* 1 — auth failed (логин/пароль отклонены)
|
||||
* 2 — строка «Баланс ГЦК» не найдена (разметка кабинета изменилась)
|
||||
* 3 — timeout (60s)
|
||||
* 4 — invalid input или другая ошибка
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
async function readNumbers(args) {
|
||||
let browser = null;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
|
||||
const loginSelector = '#loginform-username';
|
||||
await page.fill(loginSelector, args.login);
|
||||
await page.fill('#loginform-password', args.password);
|
||||
await page.click('button[type=submit]');
|
||||
await page
|
||||
.waitForFunction((sel) => !document.querySelector(sel), loginSelector, { timeout: TIMEOUT_MS })
|
||||
.catch(() => {});
|
||||
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
|
||||
if ((await page.locator(loginSelector).count()) > 0) {
|
||||
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Раскрыть выпадашку «Баланс» (js-dropdown в шапке), затем прочитать table.balancetbl.
|
||||
await page.getByText('Баланс', { exact: true }).first().click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Найти в таблице баланса строку «Баланс ГЦК» и извлечь число.
|
||||
const numbers = await page.evaluate(() => {
|
||||
const rows = Array.from(document.querySelectorAll('table.balancetbl tr'));
|
||||
for (const tr of rows) {
|
||||
const cells = tr.querySelectorAll('td');
|
||||
if (cells.length >= 2 && /Баланс\s*ГЦК/i.test(cells[0].textContent || '')) {
|
||||
const raw = (cells[1].textContent || '').replace(/[^\d-]/g, '');
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (numbers === null) {
|
||||
process.stderr.write(JSON.stringify({ error: 'строка «Баланс ГЦК» не найдена (разметка кабинета изменилась)' }));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ numbers }));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.stderr.write(JSON.stringify({ error: err.message }));
|
||||
process.exit(err.message && err.message.includes('Timeout') ? 3 : 4);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let input = '';
|
||||
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
let args;
|
||||
try {
|
||||
args = JSON.parse(input);
|
||||
} catch (e) {
|
||||
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
|
||||
process.exit(4);
|
||||
}
|
||||
if (!args.login || !args.password || !args.url) {
|
||||
process.stderr.write(JSON.stringify({ error: 'missing required keys: login, password, url' }));
|
||||
process.exit(4);
|
||||
}
|
||||
readNumbers(args).catch((err) => {
|
||||
const message = err && err.message ? err.message : String(err);
|
||||
process.stderr.write(JSON.stringify({ error: message }));
|
||||
process.exit(4);
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,10 @@ interface AdminTenantsStats {
|
||||
|
||||
export interface ListAdminTenantsParams {
|
||||
status?: string;
|
||||
/** Производные статусы UI (trial/overdue/active/suspended), csv — серверный multi-фильтр. */
|
||||
statuses?: string;
|
||||
/** Имена тарифов (tariff_plans.name), csv — серверный multi-фильтр. */
|
||||
tariffs?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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;
|
||||
};
|
||||
balances: {
|
||||
light: Light | 'grey';
|
||||
count: number;
|
||||
red: number;
|
||||
};
|
||||
clients: {
|
||||
light: Light;
|
||||
total_active: number;
|
||||
new_count: number;
|
||||
logged_in: number;
|
||||
dormant: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientsDetail {
|
||||
kpi: {
|
||||
total_active: number;
|
||||
new_count: number;
|
||||
logged_in: number;
|
||||
got_leads: number;
|
||||
paid: number;
|
||||
};
|
||||
new_clients: Array<{
|
||||
id: number;
|
||||
organization_name: string;
|
||||
subdomain: string;
|
||||
status: string;
|
||||
created_at: string | null;
|
||||
last_login_at: string | null;
|
||||
delivered_in_month: number;
|
||||
balance_rub: string;
|
||||
}>;
|
||||
dormant: Array<{
|
||||
id: number;
|
||||
organization_name: string;
|
||||
subdomain: string;
|
||||
last_login_at: string | null;
|
||||
balance_rub: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BalancesDetail {
|
||||
light: Light | 'grey';
|
||||
services: Array<{
|
||||
service_key: string;
|
||||
balance_amount: string | null;
|
||||
currency: string;
|
||||
daily_spend_estimate: string | null;
|
||||
days_left: number | null;
|
||||
light: Light | 'grey';
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
checked_at: string | null;
|
||||
topup_url: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface LeadsDetail {
|
||||
light: Light;
|
||||
kpi: {
|
||||
delivered_today: number;
|
||||
received_today: number;
|
||||
stuck: number;
|
||||
unrouted: number;
|
||||
};
|
||||
recent: Array<{
|
||||
id: number;
|
||||
received_at: string;
|
||||
platform: string;
|
||||
channel: string | null;
|
||||
source: string | null;
|
||||
phone_masked: string;
|
||||
delivered: boolean;
|
||||
processed: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
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 }>;
|
||||
}
|
||||
|
||||
/** Параметры периода: либо preset `period`, либо свой диапазон `date_from`/`date_to`. */
|
||||
export interface PeriodParams {
|
||||
period?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}
|
||||
|
||||
export async function getDashboardSummary(params: PeriodParams): Promise<DashboardSummary> {
|
||||
const { data } = await apiClient.get<DashboardSummary>('/api/admin/dashboard', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardFinance(params: PeriodParams): Promise<FinanceDetail> {
|
||||
const { data } = await apiClient.get<FinanceDetail>('/api/admin/dashboard/finance', { params });
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getDashboardBalances(): Promise<BalancesDetail> {
|
||||
const { data } = await apiClient.get<BalancesDetail>('/api/admin/dashboard/balances');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getDashboardClients(params: PeriodParams): Promise<ClientsDetail> {
|
||||
const { data } = await apiClient.get<ClientsDetail>('/api/admin/dashboard/clients', { params });
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
/**
|
||||
* SaaS-admin «Лиды» (L3) — сквозная вложенность дашборда до источника.
|
||||
* Серверная пагинация/фильтры. Backend: AdminLeadsController.
|
||||
* Spec: docs/superpowers/specs/2026-06-28-dashboard-drilldown-scale-design.md
|
||||
*/
|
||||
|
||||
export type LeadStatus = 'delivered' | 'no_match' | 'pending' | 'stuck' | 'error';
|
||||
|
||||
export interface LeadRow {
|
||||
id: number;
|
||||
received_at: string;
|
||||
platform: string;
|
||||
channel: string | null;
|
||||
source: string | null;
|
||||
region_code: number | null;
|
||||
phone_masked: string;
|
||||
deals_created_count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LeadsPage {
|
||||
data: LeadRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface LeadsFilters {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
channel?: string;
|
||||
platform?: string;
|
||||
status?: string;
|
||||
tenant_id?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface LeadDetail {
|
||||
lead: {
|
||||
id: number;
|
||||
platform: string;
|
||||
phone_masked: string;
|
||||
received_at: string;
|
||||
processed_at: string | null;
|
||||
error: string | null;
|
||||
region_code: number | null;
|
||||
region_source: string | null;
|
||||
phone_operator: string | null;
|
||||
deals_created_count: number;
|
||||
status: string;
|
||||
};
|
||||
source: {
|
||||
platform: string;
|
||||
channel: string | null;
|
||||
identifier: string | null;
|
||||
supplier_project_id: number | null;
|
||||
};
|
||||
deals: Array<{
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
tenant_name: string;
|
||||
subdomain: string;
|
||||
status: string;
|
||||
project_id: number | null;
|
||||
received_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function getLeads(filters: LeadsFilters): Promise<LeadsPage> {
|
||||
const { data } = await apiClient.get<LeadsPage>('/api/admin/leads', { params: filters });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getLead(id: number | string): Promise<LeadDetail> {
|
||||
const { data } = await apiClient.get<LeadDetail>(`/api/admin/leads/${id}`);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
// ——— DTOs ———
|
||||
|
||||
export type RunKind = 'search' | 'study' | 'resolve';
|
||||
export type RunStatus = 'queued' | 'running' | 'done' | 'empty' | 'failed';
|
||||
|
||||
export interface RunDto {
|
||||
id: number;
|
||||
kind: RunKind;
|
||||
status: RunStatus;
|
||||
region_code: number | null;
|
||||
params: Record<string, unknown>;
|
||||
price_rub_charged: string | null;
|
||||
error_code: string | null;
|
||||
competitors_count: number;
|
||||
sources_count: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string | null;
|
||||
competitor_id: number | null;
|
||||
}
|
||||
|
||||
export type Box = 'proposal' | 'field';
|
||||
export type PhoneType = 'city' | 'mobile' | 'tollfree' | null;
|
||||
|
||||
export interface CompetitorDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_federal: boolean;
|
||||
relevance_pct: number | null;
|
||||
origin: 'auto' | 'manual' | 'resolve';
|
||||
box: Box;
|
||||
site_url: string | null;
|
||||
directory_urls: string[];
|
||||
studied_at: string | null;
|
||||
study_run_id: number | null;
|
||||
search_run_id: number | null;
|
||||
}
|
||||
|
||||
export interface SourceDto {
|
||||
id: number;
|
||||
competitor_id: number;
|
||||
signal_type: 'site' | 'call';
|
||||
identifier: string;
|
||||
phone_kind: 'real' | 'substitute' | null;
|
||||
phone_type: PhoneType;
|
||||
box: Box;
|
||||
provenance_url: string | null;
|
||||
provenance_label: string | null;
|
||||
created_project_id: number | null;
|
||||
existing_project_id?: number | null;
|
||||
}
|
||||
|
||||
/** Статус проекта, привязанного к источнику (для рабочего места «поле»). */
|
||||
export interface SourceProjectDto {
|
||||
id: number;
|
||||
name: string;
|
||||
signal_identifier: string | null;
|
||||
is_active: boolean;
|
||||
paused_at: string | null;
|
||||
preflight_blocked_at: string | null;
|
||||
daily_limit_target: number;
|
||||
delivered_in_month: number;
|
||||
delivery_days_mask: number;
|
||||
regions: number[];
|
||||
}
|
||||
|
||||
/** Ответ смены источника проекта (change_source, §14.10). */
|
||||
export interface ChangeSourceResult {
|
||||
applies_from?: string | null;
|
||||
source_locked?: boolean;
|
||||
source_change_message?: string | null;
|
||||
}
|
||||
|
||||
export interface FieldSourceDto extends SourceDto {
|
||||
project: SourceProjectDto | null;
|
||||
}
|
||||
|
||||
export interface FieldCompetitorDto extends CompetitorDto {
|
||||
counters: { sources: number; projects_created: number; projects_in_work: number };
|
||||
sources: FieldSourceDto[];
|
||||
}
|
||||
|
||||
export interface StateDto {
|
||||
enabled: boolean;
|
||||
runs: RunDto[];
|
||||
prices: { search: string; study: string };
|
||||
}
|
||||
|
||||
// ——— API functions ———
|
||||
|
||||
export async function fetchState(): Promise<StateDto> {
|
||||
const { data } = await apiClient.get<StateDto>('/api/autopodbor/state');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRun(id: number): Promise<RunDto> {
|
||||
const { data } = await apiClient.get<{ data: RunDto }>(`/api/autopodbor/runs/${id}`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchCompetitor(
|
||||
id: number,
|
||||
): Promise<{ competitor: CompetitorDto; sources: FieldSourceDto[] }> {
|
||||
const { data } = await apiClient.get<{ data: CompetitorDto; sources: FieldSourceDto[] }>(
|
||||
`/api/autopodbor/competitors/${id}`,
|
||||
);
|
||||
return { competitor: data.data, sources: data.sources };
|
||||
}
|
||||
|
||||
export async function startSearch(p: {
|
||||
region_code: number;
|
||||
examples: string[];
|
||||
about_self: string[];
|
||||
include_federal: boolean;
|
||||
}): Promise<RunDto> {
|
||||
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/search', p);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function startStudy(competitor_id: number): Promise<RunDto> {
|
||||
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/study', { competitor_id });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function startResolve(p: { name: string; region_code: number }): Promise<RunDto> {
|
||||
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/resolve', p);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function startManualStudy(p: {
|
||||
competitor_id?: number;
|
||||
name?: string;
|
||||
site_url?: string;
|
||||
directory?: string;
|
||||
region_code: number;
|
||||
}): Promise<RunDto> {
|
||||
const { data } = await apiClient.post<{ data: RunDto }>('/api/autopodbor/manual-study', p);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function addManualSource(p: { competitor_id: number; raw: string }): Promise<SourceDto> {
|
||||
const { data } = await apiClient.post<{ data: SourceDto }>('/api/autopodbor/sources/manual', p);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createProjects(p: {
|
||||
source_ids: number[];
|
||||
regions: number[];
|
||||
daily_limit_target: number;
|
||||
delivery_days_mask: number;
|
||||
launch: boolean;
|
||||
}): Promise<Array<{ id: number; name: string }>> {
|
||||
const { data } = await apiClient.post<{ data: Array<{ id: number; name: string }> }>('/api/autopodbor/projects', p);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function fetchRunCompetitors(runId: number): Promise<CompetitorDto[]> {
|
||||
const { data } = await apiClient.get<{ data: CompetitorDto[] }>(`/api/autopodbor/runs/${runId}/competitors`);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
|
||||
|
||||
/** Конкуренты в поле с источниками в работе и счётчиками. */
|
||||
export async function fetchField(): Promise<FieldCompetitorDto[]> {
|
||||
const { data } = await apiClient.get<{ competitors: FieldCompetitorDto[] }>('/api/autopodbor/field');
|
||||
return data.competitors;
|
||||
}
|
||||
|
||||
/** Конкуренты в ящике «предложения» (сорт по похожести). */
|
||||
export async function fetchProposals(): Promise<CompetitorDto[]> {
|
||||
const { data } = await apiClient.get<{ data: CompetitorDto[] }>('/api/autopodbor/proposals');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function setCompetitorBox(id: number, box: Box): Promise<CompetitorDto> {
|
||||
const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}/box`, { box });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function setSourceBox(id: number, box: Box): Promise<SourceDto> {
|
||||
const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}/box`, { box });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export interface CompetitorPatch {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
is_federal?: boolean;
|
||||
relevance_pct?: number | null;
|
||||
site_url?: string | null;
|
||||
directory_urls?: string[];
|
||||
box?: Box;
|
||||
}
|
||||
|
||||
export async function updateCompetitor(id: number, patch: CompetitorPatch): Promise<CompetitorDto> {
|
||||
const { data } = await apiClient.patch<{ data: CompetitorDto }>(`/api/autopodbor/competitors/${id}`, patch);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteCompetitor(id: number): Promise<void> {
|
||||
await apiClient.delete(`/api/autopodbor/competitors/${id}`);
|
||||
}
|
||||
|
||||
export interface SourcePatch {
|
||||
identifier?: string;
|
||||
phone_kind?: 'real' | 'substitute' | null;
|
||||
phone_type?: PhoneType;
|
||||
provenance_url?: string | null;
|
||||
provenance_label?: string | null;
|
||||
box?: Box;
|
||||
}
|
||||
|
||||
export async function updateSource(id: number, patch: SourcePatch): Promise<SourceDto> {
|
||||
const { data } = await apiClient.patch<{ data: SourceDto }>(`/api/autopodbor/sources/${id}`, patch);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteSource(id: number): Promise<void> {
|
||||
await apiClient.delete(`/api/autopodbor/sources/${id}`);
|
||||
}
|
||||
|
||||
export async function createManualCompetitor(p: {
|
||||
name: string;
|
||||
description?: string;
|
||||
site_url?: string;
|
||||
directory?: string;
|
||||
is_federal?: boolean;
|
||||
}): Promise<CompetitorDto> {
|
||||
const { data } = await apiClient.post<{ data: CompetitorDto }>('/api/autopodbor/competitors/manual', p);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Включить/выключить проект источника через ГОТОВУЮ ручку проектов —
|
||||
* там все гварды (слепок 18:00 МСК, баланс, сделки, §14.9).
|
||||
*/
|
||||
export async function toggleProjectActive(projectId: number, active: boolean): Promise<void> {
|
||||
await apiClient.patch(`/api/projects/${projectId}/toggle-active`, { is_active: active });
|
||||
}
|
||||
|
||||
/**
|
||||
* Сменить источник проекта (адрес/номер) через ГОТОВУЮ ручку проектов — это и есть
|
||||
* change_source со всеми гвардами §14.10 (тип источника не меняется). Возвращает
|
||||
* сообщение о сроках вступления в силу.
|
||||
*/
|
||||
export async function changeProjectSource(projectId: number, signalIdentifier: string): Promise<ChangeSourceResult> {
|
||||
const { data } = await apiClient.patch<ChangeSourceResult>(`/api/projects/${projectId}`, {
|
||||
signal_identifier: signalIdentifier,
|
||||
});
|
||||
return data ?? {};
|
||||
}
|
||||
|
||||
/** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов (слепок §14.9). */
|
||||
export async function updateProjectSettings(
|
||||
projectId: number,
|
||||
p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number },
|
||||
): Promise<ChangeSourceResult> {
|
||||
const { data } = await apiClient.patch<ChangeSourceResult>(`/api/projects/${projectId}`, p);
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* «Дополнительные услуги» в Биллинге — тарифы «Конкурентного поля»:
|
||||
* сбор конкурентов (шаг 1) и сбор источников (шаг 2). Списываются только при успехе.
|
||||
* Цены — из autopodbor store (system_settings: autopodbor_price_search_rub/_study_rub).
|
||||
* Панель показывается только если фича включена.
|
||||
*/
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAutopodborStore } from '../../stores/autopodborStore';
|
||||
|
||||
const store = useAutopodborStore();
|
||||
|
||||
const enabled = computed(() => store.enabled);
|
||||
const searchPrice = computed(() => store.prices.search);
|
||||
const studyPrice = computed(() => store.prices.study);
|
||||
|
||||
onMounted(() => {
|
||||
void store.loadState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card v-if="enabled" variant="flat" border class="mt-4 ap-services">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold">Дополнительные услуги</v-card-title>
|
||||
<v-card-subtitle class="pb-2">«Конкурентное поле» — деньги списываются только при успешном результате</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<div class="ap-row">
|
||||
<div class="ap-row__name">
|
||||
<div class="font-weight-medium">Сбор конкурентов</div>
|
||||
<div class="text-caption text-medium-emphasis">Подбор похожих конкурентов по вашим примерам и региону</div>
|
||||
</div>
|
||||
<div class="ap-row__price num">{{ searchPrice }} ₽</div>
|
||||
</div>
|
||||
<v-divider class="my-2" />
|
||||
<div class="ap-row">
|
||||
<div class="ap-row__name">
|
||||
<div class="font-weight-medium">Сбор источников</div>
|
||||
<div class="text-caption text-medium-emphasis">Все источники одного конкурента (сайты и телефоны) для проектов</div>
|
||||
</div>
|
||||
<div class="ap-row__price num">{{ studyPrice }} ₽</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.ap-row__name {
|
||||
min-width: 0;
|
||||
}
|
||||
.ap-row__price {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #0f6e56;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@ import Kbd from '../ui/Kbd.vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useDealsCountStore } from '../../stores/dealsCount';
|
||||
import { useCommandPalette } from '../../composables/useCommandPalette';
|
||||
import { useAutopodborStore } from '../../stores/autopodborStore';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -20,6 +21,7 @@ interface NavItem {
|
||||
to: string;
|
||||
count?: number;
|
||||
countKey?: string;
|
||||
badge?: string;
|
||||
}
|
||||
interface NavGroup {
|
||||
eyebrow: string;
|
||||
@@ -32,9 +34,11 @@ const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
const dealsCount = useDealsCountStore();
|
||||
const { openPalette } = useCommandPalette();
|
||||
const autopodbor = useAutopodborStore();
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
|
||||
void autopodbor.loadState().catch(() => {});
|
||||
});
|
||||
|
||||
const navGroups = computed<NavGroup[]>(() => [
|
||||
@@ -42,6 +46,7 @@ const navGroups = computed<NavGroup[]>(() => [
|
||||
eyebrow: 'Работа',
|
||||
items: [
|
||||
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
|
||||
...(autopodbor.enabled ? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }] : []),
|
||||
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
|
||||
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
|
||||
{
|
||||
@@ -106,6 +111,7 @@ defineExpose({ navGroups });
|
||||
:data-tour="`nav-${item.to.replace('/', '')}`"
|
||||
>
|
||||
<span class="ld-nav-item__title">{{ item.title }}</span>
|
||||
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</span>
|
||||
<span
|
||||
v-if="resolveCount(item) > 0"
|
||||
class="ld-nav-item__badge ld-mono"
|
||||
@@ -243,4 +249,14 @@ defineExpose({ navGroups });
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--liderra-ivory);
|
||||
}
|
||||
|
||||
.ld-nav-item__new {
|
||||
font-size: 9px;
|
||||
background: var(--liderra-teal);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -25,7 +25,9 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ title: 'Командный центр', icon: 'mdi-view-dashboard-outline', to: '/admin/dashboard' },
|
||||
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
|
||||
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
|
||||
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
|
||||
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
|
||||
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
|
||||
|
||||
@@ -140,6 +140,12 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Проекты',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/autopodbor',
|
||||
name: 'autopodbor',
|
||||
component: () => import('../views/autopodbor/AutopodborView.vue'),
|
||||
meta: { layout: 'app', title: 'Конкурентное поле', requiresAuth: true, transition: 'ld-route-fadeup', devLabel: 'Конкурентное поле' },
|
||||
},
|
||||
{
|
||||
path: '/billing',
|
||||
name: 'billing',
|
||||
@@ -196,7 +202,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',
|
||||
@@ -216,6 +228,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/admin/AdminBillingView.vue'),
|
||||
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
|
||||
},
|
||||
{
|
||||
path: '/admin/leads',
|
||||
name: 'admin-leads',
|
||||
component: () => import('../views/admin/AdminLeadsView.vue'),
|
||||
meta: { layout: 'admin', title: 'Лиды', requiresAuth: true, devLabel: 'Admin Leads' },
|
||||
},
|
||||
{
|
||||
path: '/admin/leads/:id',
|
||||
name: 'admin-lead-detail',
|
||||
component: () => import('../views/admin/AdminLeadDetailView.vue'),
|
||||
meta: { layout: 'admin', title: 'Лид', requiresAuth: true, devLabel: 'Admin Lead Detail' },
|
||||
},
|
||||
{
|
||||
path: '/admin/incidents',
|
||||
name: 'admin-incidents',
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
fetchState,
|
||||
fetchRun,
|
||||
fetchCompetitor,
|
||||
startSearch,
|
||||
startStudy,
|
||||
startResolve,
|
||||
startManualStudy,
|
||||
addManualSource,
|
||||
createProjects,
|
||||
fetchRunCompetitors,
|
||||
fetchField,
|
||||
fetchProposals,
|
||||
setCompetitorBox,
|
||||
setSourceBox,
|
||||
updateCompetitor,
|
||||
deleteCompetitor,
|
||||
updateSource,
|
||||
deleteSource,
|
||||
createManualCompetitor,
|
||||
toggleProjectActive as apiToggleProjectActive,
|
||||
changeProjectSource as apiChangeProjectSource,
|
||||
updateProjectSettings as apiUpdateProjectSettings,
|
||||
type RunDto,
|
||||
type ChangeSourceResult,
|
||||
type CompetitorDto,
|
||||
type SourceDto,
|
||||
type Box,
|
||||
type CompetitorPatch,
|
||||
type SourcePatch,
|
||||
type FieldCompetitorDto,
|
||||
type FieldSourceDto,
|
||||
} from '../api/autopodbor';
|
||||
|
||||
/** Задержка между тиками опроса (вынесена для тестируемости). */
|
||||
export const POLL_MS = 2500;
|
||||
|
||||
const TERMINAL: ReadonlySet<string> = new Set(['done', 'empty', 'failed']);
|
||||
|
||||
export const useAutopodborStore = defineStore('autopodbor', () => {
|
||||
const enabled = ref(false);
|
||||
const prices = ref<{ search: string; study: string }>({ search: '0', study: '0' });
|
||||
const runs = ref<RunDto[]>([]);
|
||||
const currentRun = ref<RunDto | null>(null);
|
||||
const competitor = ref<CompetitorDto | null>(null);
|
||||
const sources = ref<FieldSourceDto[]>([]);
|
||||
const runCompetitors = ref<CompetitorDto[]>([]);
|
||||
const field = ref<FieldCompetitorDto[]>([]);
|
||||
const proposals = ref<CompetitorDto[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Internal poll handle — not exposed as reactive state.
|
||||
let _pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ——— Actions ———
|
||||
|
||||
async function loadState(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const state = await fetchState();
|
||||
enabled.value = state.enabled;
|
||||
prices.value = state.prices;
|
||||
runs.value = state.runs;
|
||||
} catch {
|
||||
// Сетевая ошибка — enabled остаётся false, не роняем UI
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function search(p: {
|
||||
region_code: number;
|
||||
examples: string[];
|
||||
about_self: string[];
|
||||
include_federal: boolean;
|
||||
}): Promise<RunDto> {
|
||||
const run = await startSearch(p);
|
||||
currentRun.value = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
async function study(competitorId: number): Promise<RunDto> {
|
||||
const run = await startStudy(competitorId);
|
||||
currentRun.value = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
async function resolve(p: { name: string; region_code: number }): Promise<RunDto> {
|
||||
const run = await startResolve(p);
|
||||
currentRun.value = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
async function manualStudy(p: {
|
||||
competitor_id?: number;
|
||||
name?: string;
|
||||
site_url?: string;
|
||||
directory?: string;
|
||||
region_code: number;
|
||||
}): Promise<RunDto> {
|
||||
const run = await startManualStudy(p);
|
||||
currentRun.value = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
async function loadCompetitor(id: number): Promise<void> {
|
||||
const result = await fetchCompetitor(id);
|
||||
competitor.value = result.competitor;
|
||||
sources.value = result.sources;
|
||||
}
|
||||
|
||||
async function addSource(p: { competitor_id: number; raw: string }): Promise<SourceDto> {
|
||||
const source = await addManualSource(p);
|
||||
sources.value.push({ ...source, project: null });
|
||||
return source;
|
||||
}
|
||||
|
||||
async function makeProjects(p: {
|
||||
source_ids: number[];
|
||||
regions: number[];
|
||||
daily_limit_target: number;
|
||||
delivery_days_mask: number;
|
||||
launch: boolean;
|
||||
}): Promise<Array<{ id: number; name: string }>> {
|
||||
return await createProjects(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Опрашивает run каждые POLL_MS мс до терминального статуса.
|
||||
*
|
||||
* Реализация: первый запрос выполняется немедленно (без начального setTimeout),
|
||||
* далее — рекурсивный setTimeout(POLL_MS). Это обеспечивает детерминированное
|
||||
* поведение с vi.useFakeTimers() + vi.runAllTimersAsync().
|
||||
*
|
||||
* Возвращает Promise, который резолвится в финальный RunDto.
|
||||
* stopPolling() отменяет ожидающий тайм-аут (текущий tick уже не прерывается).
|
||||
*/
|
||||
function pollRun(id: number, onTick?: (run: RunDto) => void): Promise<RunDto> {
|
||||
stopPolling();
|
||||
|
||||
return new Promise<RunDto>((resolve) => {
|
||||
async function tick(): Promise<void> {
|
||||
const run = await fetchRun(id);
|
||||
currentRun.value = run;
|
||||
onTick?.(run);
|
||||
|
||||
if (TERMINAL.has(run.status)) {
|
||||
resolve(run);
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule next tick only if not already cancelled by stopPolling().
|
||||
_pollTimeout = setTimeout(() => {
|
||||
_pollTimeout = null;
|
||||
tick();
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
// Start immediately — no leading delay.
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (_pollTimeout !== null) {
|
||||
clearTimeout(_pollTimeout);
|
||||
_pollTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRunCompetitors(runId: number): Promise<void> {
|
||||
runCompetitors.value = await fetchRunCompetitors(runId);
|
||||
}
|
||||
|
||||
// ——— «Конкурентное поле»: рабочее место (два ящика) ———
|
||||
|
||||
async function loadField(): Promise<void> {
|
||||
field.value = (await fetchField()) ?? [];
|
||||
}
|
||||
|
||||
async function loadProposals(): Promise<void> {
|
||||
proposals.value = (await fetchProposals()) ?? [];
|
||||
}
|
||||
|
||||
/** Перенос конкурента предложение↔поле; уход из поля убирает карточку из списка. */
|
||||
async function moveCompetitorToBox(id: number, box: Box): Promise<void> {
|
||||
await setCompetitorBox(id, box);
|
||||
if (box !== 'field') {
|
||||
field.value = field.value.filter((c) => c.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
async function editCompetitor(id: number, patch: CompetitorPatch): Promise<void> {
|
||||
const updated = await updateCompetitor(id, patch);
|
||||
const idx = field.value.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
field.value[idx] = { ...field.value[idx], ...updated };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCompetitor(id: number): Promise<void> {
|
||||
await deleteCompetitor(id);
|
||||
field.value = field.value.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
async function addFieldCompetitor(p: {
|
||||
name: string;
|
||||
description?: string;
|
||||
site_url?: string;
|
||||
directory?: string;
|
||||
is_federal?: boolean;
|
||||
}): Promise<CompetitorDto> {
|
||||
const created = await createManualCompetitor(p);
|
||||
field.value.push({
|
||||
...created,
|
||||
counters: { sources: 0, projects_created: 0, projects_in_work: 0 },
|
||||
sources: [],
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Перенос источника предложение↔в работу внутри карточки конкурента. */
|
||||
async function moveSourceToBox(competitorId: number, sourceId: number, box: Box): Promise<void> {
|
||||
await setSourceBox(sourceId, box);
|
||||
const comp = field.value.find((c) => c.id === competitorId);
|
||||
if (comp && box !== 'field') {
|
||||
comp.sources = comp.sources.filter((s) => s.id !== sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function editSource(competitorId: number, sourceId: number, patch: SourcePatch): Promise<void> {
|
||||
const updated = await updateSource(sourceId, patch);
|
||||
const comp = field.value.find((c) => c.id === competitorId);
|
||||
if (comp) {
|
||||
const idx = comp.sources.findIndex((s) => s.id === sourceId);
|
||||
if (idx !== -1) {
|
||||
comp.sources[idx] = { ...comp.sources[idx], ...updated };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSource(competitorId: number, sourceId: number): Promise<void> {
|
||||
await deleteSource(sourceId);
|
||||
const comp = field.value.find((c) => c.id === competitorId);
|
||||
if (comp) {
|
||||
comp.sources = comp.sources.filter((s) => s.id !== sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Управление проектом источника через готовую ручку проектов (все гварды там). */
|
||||
async function toggleProjectActive(projectId: number, active: boolean): Promise<void> {
|
||||
await apiToggleProjectActive(projectId, active);
|
||||
}
|
||||
|
||||
/** Смена источника проекта (change_source, §14.10) — через готовую ручку проектов. */
|
||||
async function changeProjectSource(projectId: number, identifier: string): Promise<ChangeSourceResult> {
|
||||
return await apiChangeProjectSource(projectId, identifier);
|
||||
}
|
||||
|
||||
/** Настройки проекта (лимит/регионы/дни) — через готовую ручку проектов. */
|
||||
async function updateProjectSettings(
|
||||
projectId: number,
|
||||
p: { daily_limit_target?: number; regions?: number[]; delivery_days_mask?: number },
|
||||
): Promise<ChangeSourceResult> {
|
||||
return await apiUpdateProjectSettings(projectId, p);
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
enabled,
|
||||
prices,
|
||||
runs,
|
||||
currentRun,
|
||||
competitor,
|
||||
sources,
|
||||
runCompetitors,
|
||||
field,
|
||||
proposals,
|
||||
loading,
|
||||
// Actions
|
||||
loadState,
|
||||
search,
|
||||
study,
|
||||
resolve,
|
||||
manualStudy,
|
||||
loadCompetitor,
|
||||
addSource,
|
||||
makeProjects,
|
||||
pollRun,
|
||||
stopPolling,
|
||||
loadRunCompetitors,
|
||||
// «Конкурентное поле»
|
||||
loadField,
|
||||
loadProposals,
|
||||
moveCompetitorToBox,
|
||||
editCompetitor,
|
||||
removeCompetitor,
|
||||
addFieldCompetitor,
|
||||
moveSourceToBox,
|
||||
editSource,
|
||||
removeSource,
|
||||
toggleProjectActive,
|
||||
changeProjectSource,
|
||||
updateProjectSettings,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import BalanceCard from '../components/billing/BalanceCard.vue';
|
||||
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
|
||||
import AutopodborServicesPanel from '../components/billing/AutopodborServicesPanel.vue';
|
||||
import TransactionsTable from '../components/billing/TransactionsTable.vue';
|
||||
import InvoicesTable from '../components/billing/InvoicesTable.vue';
|
||||
import TopupDialog from '../components/billing/TopupDialog.vue';
|
||||
@@ -131,6 +132,8 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
|
||||
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
|
||||
|
||||
<AutopodborServicesPanel />
|
||||
|
||||
<TransactionsTable ref="txTableRef" />
|
||||
|
||||
<InvoicesTable />
|
||||
|
||||
@@ -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: 'Проекты, которые сейчас собирают заявки.',
|
||||
},
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка → Карточка лида (L4) — конечный источник: ОТКУДА пришёл лид
|
||||
* (поставщик-проект + канал + регион) → КОМУ ушёл (сделки клиентов).
|
||||
* Завершает сквозную вложенность дашборда (плитка Лиды → список → сюда).
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getLead, type LeadDetail } from '../../api/adminLeads';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const detail = ref<LeadDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
delivered: { label: 'доставлен', color: 'success' },
|
||||
no_match: { label: 'без получателя', color: 'warning' },
|
||||
stuck: { label: 'завис', color: 'error' },
|
||||
pending: { label: 'в обработке', color: 'info' },
|
||||
error: { label: 'ошибка', color: 'error' },
|
||||
};
|
||||
function statusMeta(s: string) {
|
||||
return STATUS_META[s] ?? { label: s, color: 'grey' };
|
||||
}
|
||||
function channelLabel(c: string | null): string {
|
||||
return c === 'site' ? 'Сайт' : c === 'call' ? 'Звонок' : c === 'sms' ? 'SMS' : '—';
|
||||
}
|
||||
function fmtDate(v: string | null): string {
|
||||
if (!v) return '—';
|
||||
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/);
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]} ${m[4]}` : v;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
detail.value = await getLead(route.params.id as string);
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function openTenant(subdomain: string) {
|
||||
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
defineExpose({ detail, loading, fetchError, load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="lead-detail pa-6">
|
||||
<div class="d-flex align-center justify-space-between mb-3 flex-wrap ga-3">
|
||||
<h1 class="text-h5 font-weight-bold">Лид #{{ route.params.id }}</h1>
|
||||
<v-btn variant="text" class="text-none" prepend-icon="mdi-arrow-left" to="/admin/leads">Все лиды</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" class="mb-4">
|
||||
Не удалось загрузить лид.
|
||||
</v-alert>
|
||||
|
||||
<template v-if="detail">
|
||||
<v-row>
|
||||
<!-- ОТКУДА -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" class="h-100" data-testid="lead-source">
|
||||
<v-card-title class="card-h">📥 Откуда пришёл</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="kv"><span>Поставщик</span><b>{{ detail.source.platform }}</b></div>
|
||||
<div class="kv"><span>Канал</span><b>{{ channelLabel(detail.source.channel) }}</b></div>
|
||||
<div class="kv"><span>Источник</span><b>{{ detail.source.identifier ?? '—' }}</b></div>
|
||||
<div class="kv"><span>Регион (код РФ)</span><b>{{ detail.lead.region_code ?? '—' }}</b></div>
|
||||
<div class="kv"><span>Оператор</span><b>{{ detail.lead.phone_operator ?? '—' }}</b></div>
|
||||
<div class="kv"><span>Телефон</span><b class="num">{{ detail.lead.phone_masked }}</b></div>
|
||||
<v-btn
|
||||
v-if="detail.source.supplier_project_id"
|
||||
variant="text" size="small" class="text-none mt-2 px-0"
|
||||
to="/admin/supplier-projects"
|
||||
>
|
||||
Открыть в «Проектах у поставщика» →
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- ЧТО / СТАТУС -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" class="h-100">
|
||||
<v-card-title class="card-h">ℹ️ Лид</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="kv"><span>Получен</span><b class="num">{{ fmtDate(detail.lead.received_at) }}</b></div>
|
||||
<div class="kv"><span>Обработан</span><b class="num">{{ fmtDate(detail.lead.processed_at) }}</b></div>
|
||||
<div class="kv">
|
||||
<span>Статус</span>
|
||||
<v-chip :color="statusMeta(detail.lead.status).color" size="x-small" variant="tonal">
|
||||
{{ statusMeta(detail.lead.status).label }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="kv"><span>Создано сделок</span><b>{{ detail.lead.deals_created_count }}</b></div>
|
||||
<div v-if="detail.lead.error" class="kv">
|
||||
<span>Ошибка</span><b class="text-error">{{ detail.lead.error }}</b>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- КОМУ -->
|
||||
<v-card variant="outlined" class="mt-4" data-testid="lead-deals">
|
||||
<v-card-title class="card-h">📤 Кому ушёл — сделки клиентов</v-card-title>
|
||||
<v-card-text>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr><th>Клиент</th><th>Статус сделки</th><th>Получена</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="d in detail.deals" :key="d.id" class="clk" @click="openTenant(d.subdomain)">
|
||||
<td>{{ d.tenant_name }}</td>
|
||||
<td>{{ d.status }}</td>
|
||||
<td class="num">{{ fmtDate(d.received_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="detail.deals.length === 0">
|
||||
<td colspan="3" class="text-center text-medium-emphasis">
|
||||
Сделок по этому лиду нет (не распределён или нет совпадений у клиентов).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead-detail { max-width: 1100px; }
|
||||
.card-h { font-size: 15px; font-weight: 700; }
|
||||
.kv { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.05); }
|
||||
.kv span { color: rgba(0,0,0,0.6); }
|
||||
.num { font-family: 'JetBrains Mono', 'Consolas', monospace; font-variant-numeric: tabular-nums; }
|
||||
.clk:hover { background: rgba(15, 110, 86, 0.06); cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка → Лиды (L3). Полный список лидов с серверными фильтрами/пагинацией
|
||||
* (масштаб: десятки тысяч лидов). Клик по строке → карточка лида (L4, цепочка).
|
||||
* Сюда ведёт «Открыть все лиды →» из дашборда (плитка Лиды).
|
||||
*/
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getLeads, type LeadRow, type LeadsFilters } from '../../api/adminLeads';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const rows = ref<LeadRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const perPage = ref(25);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
const filters = ref<LeadsFilters>({
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
channel: (route.query.channel as string) || '',
|
||||
platform: '',
|
||||
status: '',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const CHANNELS = [
|
||||
{ value: '', title: 'Все каналы' },
|
||||
{ value: 'site', title: 'Сайт' },
|
||||
{ value: 'call', title: 'Звонок' },
|
||||
{ value: 'sms', title: 'SMS' },
|
||||
];
|
||||
const PLATFORMS = [
|
||||
{ value: '', title: 'Все поставщики' },
|
||||
{ value: 'B1', title: 'B1' },
|
||||
{ value: 'B2', title: 'B2' },
|
||||
{ value: 'B3', title: 'B3' },
|
||||
{ value: 'DIRECT', title: 'Напрямую' },
|
||||
];
|
||||
const STATUSES = [
|
||||
{ value: '', title: 'Любой статус' },
|
||||
{ value: 'delivered', title: 'Доставлен' },
|
||||
{ value: 'no_match', title: 'Без получателя' },
|
||||
{ value: 'stuck', title: 'Завис' },
|
||||
{ value: 'pending', title: 'В обработке' },
|
||||
{ value: 'error', title: 'Ошибка' },
|
||||
];
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
delivered: { label: 'доставлен', color: 'success' },
|
||||
no_match: { label: 'без получателя', color: 'warning' },
|
||||
stuck: { label: 'завис', color: 'error' },
|
||||
pending: { label: 'в обработке', color: 'info' },
|
||||
error: { label: 'ошибка', color: 'error' },
|
||||
};
|
||||
function statusMeta(s: string) {
|
||||
return STATUS_META[s] ?? { label: s, color: 'grey' };
|
||||
}
|
||||
function channelLabel(c: string | null): string {
|
||||
return c === 'site' ? 'Сайт' : c === 'call' ? 'Звонок' : c === 'sms' ? 'SMS' : '—';
|
||||
}
|
||||
function fmtDate(v: string): string {
|
||||
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/);
|
||||
return m ? `${m[3]}.${m[2]} ${m[4]}` : v;
|
||||
}
|
||||
|
||||
const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value));
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const res = await getLeads({ ...filters.value, page: page.value, per_page: perPage.value });
|
||||
rows.value = res.data;
|
||||
total.value = res.total;
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
function goPage(p: number) {
|
||||
page.value = p;
|
||||
void load();
|
||||
}
|
||||
function openLead(id: number) {
|
||||
router.push({ name: 'admin-lead-detail', params: { id: String(id) } });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
defineExpose({ rows, total, page, perPage, filters, loading, fetchError, load, applyFilters });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-leads pa-6">
|
||||
<div class="d-flex align-center justify-space-between mb-3 flex-wrap ga-3">
|
||||
<h1 class="text-h5 font-weight-bold">Лиды</h1>
|
||||
<v-btn variant="text" class="text-none" prepend-icon="mdi-view-dashboard-outline" to="/admin/dashboard">
|
||||
← Командный центр
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<v-card variant="outlined" class="mb-4">
|
||||
<v-card-text class="d-flex flex-wrap align-center ga-3">
|
||||
<v-text-field
|
||||
v-model="filters.date_from" type="date" label="С" density="compact" variant="outlined"
|
||||
hide-details style="max-width: 160px" data-testid="f-date-from" />
|
||||
<v-text-field
|
||||
v-model="filters.date_to" type="date" label="По" density="compact" variant="outlined"
|
||||
hide-details style="max-width: 160px" data-testid="f-date-to" />
|
||||
<v-select
|
||||
v-model="filters.channel" :items="CHANNELS" label="Канал" density="compact" variant="outlined"
|
||||
hide-details style="max-width: 160px" data-testid="f-channel" />
|
||||
<v-select
|
||||
v-model="filters.platform" :items="PLATFORMS" label="Поставщик" density="compact" variant="outlined"
|
||||
hide-details style="max-width: 170px" data-testid="f-platform" />
|
||||
<v-select
|
||||
v-model="filters.status" :items="STATUSES" label="Статус" density="compact" variant="outlined"
|
||||
hide-details style="max-width: 180px" data-testid="f-status" />
|
||||
<v-text-field
|
||||
v-model="filters.search" label="Поиск (телефон / источник)" density="compact" variant="outlined"
|
||||
hide-details style="max-width: 240px" data-testid="f-search" @keyup.enter="applyFilters" />
|
||||
<v-btn color="primary" class="text-none" data-testid="apply-filters" @click="applyFilters">Найти</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-alert v-if="fetchError" type="warning" variant="tonal" density="compact" closable class="mb-4">
|
||||
Не удалось загрузить лиды. Попробуйте обновить.
|
||||
</v-alert>
|
||||
|
||||
<v-card variant="outlined">
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Канал</th>
|
||||
<th>Источник</th>
|
||||
<th>Поставщик</th>
|
||||
<th>Регион</th>
|
||||
<th>Телефон</th>
|
||||
<th class="text-right">Клиентов</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="l in rows" :key="l.id" class="clk" @click="openLead(l.id)">
|
||||
<td class="num">{{ fmtDate(l.received_at) }}</td>
|
||||
<td>{{ channelLabel(l.channel) }}</td>
|
||||
<td>{{ l.source ?? '—' }}</td>
|
||||
<td>{{ l.platform }}</td>
|
||||
<td class="num">{{ l.region_code ?? '—' }}</td>
|
||||
<td class="num">{{ l.phone_masked }}</td>
|
||||
<td class="text-right num">{{ l.deals_created_count }}</td>
|
||||
<td>
|
||||
<v-chip :color="statusMeta(l.status).color" size="x-small" variant="tonal">
|
||||
{{ statusMeta(l.status).label }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0 && !loading">
|
||||
<td colspan="8" class="text-center text-medium-emphasis">Лидов по фильтрам не найдено</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mt-3 flex-wrap ga-2">
|
||||
<span class="text-medium-emphasis text-body-2">Всего: {{ total }}</span>
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="totalPages()"
|
||||
:total-visible="7"
|
||||
density="compact"
|
||||
data-testid="pager"
|
||||
@update:model-value="goPage"
|
||||
/>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-leads { max-width: 1280px; }
|
||||
.num { font-family: 'JetBrains Mono', 'Consolas', monospace; font-variant-numeric: tabular-nums; }
|
||||
.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>
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
/**
|
||||
* Админка → Тенанты. Список всех тенантов SaaS с балансами/тарифами/MRR.
|
||||
*
|
||||
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): UI-блоки выделены в
|
||||
* components/admin/tenants/{TenantsStatsHeader,TenantsFilters,TenantsTable}.
|
||||
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.)
|
||||
* остаётся в этом view ради `defineExpose`-контракта, который Vitest тесты
|
||||
* используют для прямого доступа.
|
||||
* Масштаб (28.06.2026): серверная пагинация + серверные фильтры (search/статус/тариф).
|
||||
* Раньше грузили всех разом и фильтровали в браузере — на 1000 клиентов это не
|
||||
* «смотрибельно» (поиск/чипы видели только первую страницу). Теперь:
|
||||
* - страница из `limit/offset` (perPage), счётчик `total` с сервера → v-pagination;
|
||||
* - поиск (org/subdomain/email ILIKE) — серверный, debounce 400мс;
|
||||
* - статус (производный trial/overdue/active/suspended) и тариф — серверные multi.
|
||||
* Бэкенд: AdminTenantsController::index (statuses/tariffs/search/limit/offset/total).
|
||||
*
|
||||
* State (filterStatuses/filterTariffs/clearFilters/tenantsState/stats и др.) остаётся
|
||||
* в этом view ради `defineExpose`-контракта Vitest-тестов.
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html секция #page-tenants.
|
||||
* По схеме v8.7 §3 (tenants table) + ТЗ §22 (админка).
|
||||
*
|
||||
* Click по строке → /admin/tenants/{code} (карточка тенанта).
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { type AdminTenant, type TenantStatus } from '../../composables/mockTenants';
|
||||
import { mapApiAdminTenant } from '../../composables/adminTenantsMapper';
|
||||
@@ -35,34 +38,93 @@ const stats = reactive({ total: 0, active: 0, trial: 0, overdue: 0, monthlyReven
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
async function loadTenants() {
|
||||
const search = ref('');
|
||||
const filterStatuses = ref<TenantStatus[]>([]);
|
||||
const filterTariffs = ref<string[]>([]);
|
||||
const availableTariffs = ref<string[]>([]);
|
||||
|
||||
// Серверная пагинация.
|
||||
const page = ref(1);
|
||||
const perPage = ref(25);
|
||||
const total = ref(0);
|
||||
const totalPages = () => Math.max(1, Math.ceil(total.value / perPage.value));
|
||||
|
||||
async function loadTenants(): Promise<void> {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const res = await adminApi.listAdminTenants();
|
||||
const res = await adminApi.listAdminTenants({
|
||||
search: search.value.trim(),
|
||||
statuses: filterStatuses.value.join(','),
|
||||
tariffs: filterTariffs.value.join(','),
|
||||
limit: perPage.value,
|
||||
offset: (page.value - 1) * perPage.value,
|
||||
});
|
||||
const mapped = res.tenants.map((t) => mapApiAdminTenant(t));
|
||||
tenantsState.splice(0, tenantsState.length, ...mapped);
|
||||
total.value = res.total;
|
||||
stats.total = res.stats.total;
|
||||
stats.active = res.stats.active;
|
||||
stats.trial = res.stats.trial;
|
||||
stats.overdue = res.stats.overdue;
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
tenantsState.splice(0, tenantsState.length);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTenants);
|
||||
// Опции тарифов для дропдауна — отдельным запросом (на странице видна только часть
|
||||
// тенантов, поэтому список тарифов нельзя выводить из загруженного набора).
|
||||
async function loadTariffOptions(): Promise<void> {
|
||||
try {
|
||||
const plans = await adminApi.listAdminTariffPlans();
|
||||
availableTariffs.value = Array.from(new Set(plans.map((p) => p.name))).sort();
|
||||
} catch {
|
||||
// дропдаун останется пустым — не критично для основного списка.
|
||||
}
|
||||
}
|
||||
|
||||
// Поиск — debounce 400мс (планшет: печатает → ищет, без кнопки «Найти»).
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(search, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1;
|
||||
void loadTenants();
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// Фильтры — сразу перезагрузка с 1-й страницы.
|
||||
watch(
|
||||
[filterStatuses, filterTariffs],
|
||||
() => {
|
||||
page.value = 1;
|
||||
void loadTenants();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function goPage(p: number): void {
|
||||
page.value = p;
|
||||
void loadTenants();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTariffOptions();
|
||||
void loadTenants();
|
||||
});
|
||||
usePolling(loadTenants);
|
||||
|
||||
function openTenantDetail(t: AdminTenant) {
|
||||
function openTenantDetail(t: AdminTenant): void {
|
||||
router.push({ name: 'admin-tenant-detail', params: { code: t.code } });
|
||||
}
|
||||
|
||||
const search = ref('');
|
||||
const filterStatuses = ref<TenantStatus[]>([]);
|
||||
const filterTariffs = ref<string[]>([]);
|
||||
function clearFilters(): void {
|
||||
filterStatuses.value = [];
|
||||
filterTariffs.value = [];
|
||||
}
|
||||
|
||||
const impersonationOpen = ref(false);
|
||||
const impersonationTenant = ref<AdminTenant | null>(null);
|
||||
@@ -70,21 +132,14 @@ const impersonationTenant = ref<AdminTenant | null>(null);
|
||||
const balanceDialogOpen = ref(false);
|
||||
const balanceTarget = ref<AdminTenant | null>(null);
|
||||
|
||||
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
|
||||
|
||||
function clearFilters() {
|
||||
filterStatuses.value = [];
|
||||
filterTariffs.value = [];
|
||||
}
|
||||
|
||||
const ADMIN_USER_ID = 1;
|
||||
|
||||
function openImpersonation(tenant: AdminTenant) {
|
||||
function openImpersonation(tenant: AdminTenant): void {
|
||||
impersonationTenant.value = tenant;
|
||||
impersonationOpen.value = true;
|
||||
}
|
||||
|
||||
function openBalanceDialog(tenant: AdminTenant) {
|
||||
function openBalanceDialog(tenant: AdminTenant): void {
|
||||
balanceTarget.value = tenant;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
@@ -106,22 +161,12 @@ defineExpose({
|
||||
loading,
|
||||
fetchError,
|
||||
loadTenants,
|
||||
});
|
||||
|
||||
const filteredTenants = computed<AdminTenant[]>(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const statuses = new Set(filterStatuses.value);
|
||||
const tariffs = new Set(filterTariffs.value);
|
||||
|
||||
return tenantsState.filter((t) => {
|
||||
if (statuses.size > 0 && !statuses.has(t.status)) return false;
|
||||
if (tariffs.size > 0 && !tariffs.has(t.tariff)) return false;
|
||||
if (q) {
|
||||
const haystack = `${t.name} ${t.inn} ${t.code}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
availableTariffs,
|
||||
goPage,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -153,12 +198,24 @@ const filteredTenants = computed<AdminTenant[]>(() => {
|
||||
/>
|
||||
|
||||
<TenantsTable
|
||||
:tenants="filteredTenants"
|
||||
:tenants="tenantsState"
|
||||
@row-click="openTenantDetail"
|
||||
@impersonate="openImpersonation"
|
||||
@edit-balance="openBalanceDialog"
|
||||
/>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mt-3 flex-wrap ga-2">
|
||||
<span class="text-medium-emphasis text-body-2">Всего: {{ total }}</span>
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="totalPages()"
|
||||
:total-visible="7"
|
||||
density="compact"
|
||||
data-testid="tenants-pager"
|
||||
@update:model-value="goPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
|
||||
|
||||
<TenantBalanceDialog
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, provide, onMounted } from 'vue';
|
||||
import { useAutopodborStore } from '../../stores/autopodborStore';
|
||||
import FieldWorkspaceScreen from './screens/FieldWorkspaceScreen.vue';
|
||||
import FieldCompetitorScreen from './screens/FieldCompetitorScreen.vue';
|
||||
import FieldProposalsScreen from './screens/FieldProposalsScreen.vue';
|
||||
import FieldManualCompetitorScreen from './screens/FieldManualCompetitorScreen.vue';
|
||||
import EntryScreen from './screens/EntryScreen.vue';
|
||||
import AutoFormScreen from './screens/AutoFormScreen.vue';
|
||||
import ManualFormScreen from './screens/ManualFormScreen.vue';
|
||||
import LoadingScreen from './screens/LoadingScreen.vue';
|
||||
import ListScreen from './screens/ListScreen.vue';
|
||||
import DetailScreen from './screens/DetailScreen.vue';
|
||||
import CreateScreen from './screens/CreateScreen.vue';
|
||||
import DoneScreen from './screens/DoneScreen.vue';
|
||||
import EditProjectScreen from './screens/EditProjectScreen.vue';
|
||||
|
||||
type ScreenName =
|
||||
| 'field'
|
||||
| 'fieldcompetitor'
|
||||
| 'field-proposals'
|
||||
| 'field-manual-competitor'
|
||||
| 'entry'
|
||||
| 'autoform'
|
||||
| 'manualform'
|
||||
| 'loading'
|
||||
| 'list'
|
||||
| 'detail'
|
||||
| 'editproject'
|
||||
| 'create'
|
||||
| 'done';
|
||||
|
||||
const store = useAutopodborStore();
|
||||
|
||||
const screen = ref<ScreenName>('field');
|
||||
|
||||
const ctx = reactive({
|
||||
runId: null as number | null,
|
||||
competitorId: null as number | null,
|
||||
selectedSourceIds: [] as number[],
|
||||
loadMsg: '',
|
||||
loadSub: '',
|
||||
editProjectId: null as number | null,
|
||||
createdCount: 0,
|
||||
launched: false,
|
||||
});
|
||||
|
||||
function go(name: ScreenName) {
|
||||
screen.value = name;
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
provide('autopodborNav', { go, ctx, screen });
|
||||
|
||||
const screens: Partial<Record<ScreenName, any>> = {
|
||||
field: FieldWorkspaceScreen,
|
||||
fieldcompetitor: FieldCompetitorScreen,
|
||||
'field-proposals': FieldProposalsScreen,
|
||||
'field-manual-competitor': FieldManualCompetitorScreen,
|
||||
entry: EntryScreen,
|
||||
autoform: AutoFormScreen,
|
||||
manualform: ManualFormScreen,
|
||||
loading: LoadingScreen,
|
||||
list: ListScreen,
|
||||
detail: DetailScreen,
|
||||
create: CreateScreen,
|
||||
done: DoneScreen,
|
||||
editproject: EditProjectScreen,
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void store.loadState();
|
||||
});
|
||||
|
||||
defineExpose({ go, screen, ctx });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ld-autopodbor">
|
||||
<component :is="screens[screen]" v-if="screens[screen]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-autopodbor {
|
||||
padding: 0 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from 'vue';
|
||||
import { useAutopodborStore } from '../../../stores/autopodborStore';
|
||||
import { REGIONS } from '../../../constants/regions';
|
||||
|
||||
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
|
||||
const store = useAutopodborStore();
|
||||
|
||||
// Список конкурентов-примеров (как минимум одно поле всегда видно)
|
||||
const examples = ref<string[]>(['', '', '']);
|
||||
const regionCode = ref<number | null>(null);
|
||||
const includeFederal = ref(true);
|
||||
const errorMsg = ref('');
|
||||
|
||||
defineExpose({ regionCode });
|
||||
|
||||
function addExample() {
|
||||
examples.value.push('');
|
||||
}
|
||||
|
||||
function extractError(e: unknown): string {
|
||||
const code = (e as any)?.response?.data?.error;
|
||||
if (code === 'balance_insufficient') return 'Недостаточно средств на балансе.';
|
||||
if (code === 'run_in_flight') return 'Уже идёт похожий запрос — дождитесь его завершения.';
|
||||
return 'Произошла ошибка. Попробуйте позже.';
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errorMsg.value = '';
|
||||
const filled = examples.value.map(e => e.trim()).filter(Boolean);
|
||||
if (filled.length === 0) {
|
||||
errorMsg.value = 'Укажите хотя бы один пример конкурента.';
|
||||
return;
|
||||
}
|
||||
if (!regionCode.value) {
|
||||
errorMsg.value = 'Выберите регион поиска.';
|
||||
return;
|
||||
}
|
||||
nav.go('loading');
|
||||
try {
|
||||
const run = await store.search({
|
||||
region_code: regionCode.value,
|
||||
examples: filled,
|
||||
about_self: [],
|
||||
include_federal: includeFederal.value,
|
||||
});
|
||||
await store.pollRun(run.id);
|
||||
nav.go('list');
|
||||
} catch (e) {
|
||||
nav.go('autoform');
|
||||
errorMsg.value = extractError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ld-autoform-screen">
|
||||
<div class="ld-af-topbar">
|
||||
<span class="ld-af-crumb">Автоподбор · Подбор конкурентов</span>
|
||||
</div>
|
||||
|
||||
<button class="ld-af-back" type="button" @click="nav.go('entry')">← Назад</button>
|
||||
|
||||
<h1 class="ld-af-title">Подобрать конкурентов</h1>
|
||||
<p class="ld-af-sub">Укажите примеры конкурентов и регион — Лидерра найдёт похожих.</p>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" class="ld-af-alert" variant="tonal" closable @click:close="errorMsg = ''">
|
||||
{{ errorMsg }}
|
||||
</v-alert>
|
||||
|
||||
<div class="ld-af-card">
|
||||
<p class="ld-af-sectitle">Ваши конкуренты <span class="ld-af-req">*</span></p>
|
||||
<p class="ld-af-hint">Чем больше примеров, тем точнее и шире подбор. Сайт конкурента или ссылка на его карточку в справочнике (2ГИС, Яндекс.Карты).</p>
|
||||
|
||||
<input
|
||||
v-for="(_, i) in examples"
|
||||
:key="i"
|
||||
v-model="examples[i]"
|
||||
class="ld-af-input"
|
||||
type="text"
|
||||
:placeholder="i === 0 ? 'okna-kazan.ru' : i === 1 ? '2gis.ru/kazan/firm/70000001…' : 'plastokna-rt.ru'"
|
||||
/>
|
||||
|
||||
<button class="ld-af-addrow" type="button" @click="addExample">+ добавить конкурента</button>
|
||||
|
||||
<div class="ld-af-divider"></div>
|
||||
|
||||
<p class="ld-af-sectitle">Регион поиска <span class="ld-af-req">*</span></p>
|
||||
<p class="ld-af-hint">Обязательно. Один регион за один подбор — иначе список будет слишком большим.</p>
|
||||
|
||||
<select v-model="regionCode" class="ld-af-select">
|
||||
<option :value="null" disabled>— выберите регион —</option>
|
||||
<option v-for="r in REGIONS.filter(r => r.code > 0)" :key="r.code" :value="r.code">{{ r.name }}</option>
|
||||
</select>
|
||||
|
||||
<label class="ld-af-check">
|
||||
<input v-model="includeFederal" type="checkbox" class="ld-af-check-input" />
|
||||
<span>Включать федеральных игроков<br />
|
||||
<span class="ld-af-muted">Крупные компании, которые работают и в вашем регионе, и в других.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="ld-af-divider"></div>
|
||||
|
||||
<button class="ld-btn-primary" type="button" @click="submit">Подобрать конкурентов</button>
|
||||
<p class="ld-af-paynote">Услуга платная — при запуске спишем сумму с баланса.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-autoform-screen {
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
.ld-af-topbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ld-af-crumb {
|
||||
font-size: 12.5px;
|
||||
color: #7a7468;
|
||||
}
|
||||
|
||||
.ld-af-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ld-af-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #012019;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ld-af-sub {
|
||||
font-size: 14px;
|
||||
color: #4a4540;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.ld-af-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ld-af-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e2d4;
|
||||
border-radius: 10px;
|
||||
padding: 22px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ld-af-sectitle {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
color: #012019;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ld-af-req {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.ld-af-hint {
|
||||
font-size: 12.5px;
|
||||
color: #7a7468;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ld-af-input {
|
||||
border: 1.5px solid #d8d2c6;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13.5px;
|
||||
color: #012019;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: border-color 150ms ease;
|
||||
background: #faf8f4;
|
||||
}
|
||||
|
||||
.ld-af-input:focus {
|
||||
border-color: var(--liderra-teal, #0f6e56);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ld-af-select {
|
||||
border: 1.5px solid #d8d2c6;
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13.5px;
|
||||
color: #012019;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
background: #faf8f4;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
|
||||
.ld-af-select:focus {
|
||||
border-color: var(--liderra-teal, #0f6e56);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ld-af-addrow {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ld-af-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
color: #012019;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ld-af-check-input {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--liderra-teal, #0f6e56);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ld-af-muted {
|
||||
color: #9b9484;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ld-af-divider {
|
||||
height: 1px;
|
||||
background: #f0ece1;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.ld-btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--liderra-teal, #0f6e56);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 10px 20px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ld-btn-primary:hover {
|
||||
background: #0b5a45;
|
||||
}
|
||||
|
||||
.ld-af-paynote {
|
||||
font-size: 11.5px;
|
||||
color: #9b9484;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,521 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed, ref } from 'vue';
|
||||
import { useAutopodborStore } from '../../../stores/autopodborStore';
|
||||
import { REGIONS } from '../../../constants/regions';
|
||||
|
||||
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
|
||||
const store = useAutopodborStore();
|
||||
|
||||
// Выбранные источники из ctx
|
||||
const selected = computed(() =>
|
||||
store.sources.filter((s) => nav.ctx.selectedSourceIds.includes(s.id)),
|
||||
);
|
||||
|
||||
// Регионы (только code > 0)
|
||||
const regions = REGIONS.filter((r) => r.code > 0);
|
||||
|
||||
// Состояние формы
|
||||
const regionCode = ref<number | null>(
|
||||
store.currentRun?.region_code ?? null,
|
||||
);
|
||||
const dailyLimit = ref<number>(20);
|
||||
// Маска дней: бит i = 1<<i, дефолт все 7 дней = 127
|
||||
const deliveryMask = ref<number>(127);
|
||||
|
||||
// Для тестируемости
|
||||
defineExpose({ regionCode, dailyLimit, deliveryMask });
|
||||
|
||||
const errorMsg = ref('');
|
||||
|
||||
// Имена дней
|
||||
const DAY_LABELS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
|
||||
function isDayOn(i: number): boolean {
|
||||
return (deliveryMask.value & (1 << i)) !== 0;
|
||||
}
|
||||
|
||||
function toggleDay(i: number): void {
|
||||
deliveryMask.value ^= 1 << i;
|
||||
}
|
||||
|
||||
// Производное имя источника
|
||||
function sourceName(src: { signal_type: string; phone_kind: string | null }): string {
|
||||
const base = store.competitor?.name ?? '';
|
||||
if (src.signal_type === 'site') return base;
|
||||
if (src.phone_kind === 'real') return `${base} ✓`;
|
||||
if (src.phone_kind === 'substitute') return `${base} 🎭`;
|
||||
return base;
|
||||
}
|
||||
|
||||
async function create(launch: boolean): Promise<void> {
|
||||
if (!regionCode.value) {
|
||||
errorMsg.value = 'Выберите регион.';
|
||||
return;
|
||||
}
|
||||
errorMsg.value = '';
|
||||
nav.ctx.loadMsg = launch ? 'Создаём и запускаем проекты…' : 'Создаём проекты…';
|
||||
nav.ctx.loadSub = 'Заводим проекты и передаём источники поставщику.';
|
||||
nav.go('loading');
|
||||
try {
|
||||
const projects = await store.makeProjects({
|
||||
source_ids: nav.ctx.selectedSourceIds,
|
||||
regions: [regionCode.value],
|
||||
daily_limit_target: dailyLimit.value,
|
||||
delivery_days_mask: deliveryMask.value,
|
||||
launch,
|
||||
});
|
||||
nav.ctx.createdCount = projects.length;
|
||||
nav.ctx.launched = launch;
|
||||
nav.go('done');
|
||||
} catch (e) {
|
||||
const code = (e as any)?.response?.data?.error;
|
||||
errorMsg.value = code === 'balance_insufficient'
|
||||
? 'Недостаточно средств для запуска всех проектов. Можно создать без запуска и пополнить баланс позже.'
|
||||
: 'Не удалось создать проекты. Попробуйте ещё раз.';
|
||||
nav.go('create');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ld-create-screen">
|
||||
<!-- Topbar -->
|
||||
<div class="ld-topbar">
|
||||
<div class="ld-crumb">
|
||||
Автоподбор
|
||||
<template v-if="store.competitor"> · {{ store.competitor.name }}</template>
|
||||
· Создание проектов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ld-create-content">
|
||||
<!-- Back -->
|
||||
<button class="ld-back" @click="nav.go('detail')">← К источникам конкурента</button>
|
||||
|
||||
<h1 class="ld-title">Создание проектов</h1>
|
||||
<p class="ld-sub">
|
||||
Каждый выбранный источник станет отдельным проектом.
|
||||
Ниже — общие настройки, применятся ко всем.
|
||||
</p>
|
||||
|
||||
<!-- Ошибка -->
|
||||
<div v-if="errorMsg" class="ld-alert">{{ errorMsg }}</div>
|
||||
|
||||
<!-- Карточка источников -->
|
||||
<div class="ld-card">
|
||||
<p class="ld-ctitle">Будет создано {{ selected.length }} проектов</p>
|
||||
<p class="ld-hint">
|
||||
Название сформировано автоматически: конкурент + значок типа номера.
|
||||
<span class="ld-mark-real">✓</span> настоящий номер ·
|
||||
<span class="ld-mark-sub">🎭</span> подменный (с сайта).
|
||||
<em>Переименование — в разделе «Проекты» после создания.</em>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="src in selected"
|
||||
:key="src.id"
|
||||
class="ld-srow"
|
||||
>
|
||||
<span
|
||||
class="ld-stype"
|
||||
:class="src.signal_type === 'site' ? 'ld-stype--site' : 'ld-stype--call'"
|
||||
>
|
||||
{{ src.signal_type === 'site' ? 'сайт' : 'звонок' }}
|
||||
</span>
|
||||
<span
|
||||
class="ld-sident"
|
||||
:class="{ 'ld-sident--site': src.signal_type === 'site' }"
|
||||
>
|
||||
{{ src.identifier }}
|
||||
<span v-if="src.phone_kind === 'real'" class="ld-mark-real">✓</span>
|
||||
<span v-if="src.phone_kind === 'substitute'" class="ld-mark-sub">🎭</span>
|
||||
</span>
|
||||
<span class="ld-derived-name">{{ sourceName(src) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Карточка настроек -->
|
||||
<div class="ld-card">
|
||||
<p class="ld-ctitle">Настройки проектов</p>
|
||||
|
||||
<div class="ld-frow">
|
||||
<div class="ld-fcol">
|
||||
<label class="ld-flabel">Регион <span class="ld-req">*</span></label>
|
||||
<select
|
||||
v-model="regionCode"
|
||||
class="ld-select"
|
||||
>
|
||||
<option :value="null" disabled>— выберите регион —</option>
|
||||
<option
|
||||
v-for="r in regions"
|
||||
:key="r.code"
|
||||
:value="r.code"
|
||||
>
|
||||
{{ r.name }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="ld-fhint">Подставлен из подбора. Можно изменить.</p>
|
||||
</div>
|
||||
|
||||
<div class="ld-fcol">
|
||||
<label class="ld-flabel">Лимит лидов в день <span class="ld-req">*</span></label>
|
||||
<input
|
||||
v-model.number="dailyLimit"
|
||||
type="number"
|
||||
min="1"
|
||||
class="ld-input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ld-days-wrap">
|
||||
<p class="ld-flabel">Дни приёма</p>
|
||||
<div class="ld-days">
|
||||
<button
|
||||
v-for="(label, i) in DAY_LABELS"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="ld-day"
|
||||
:class="{ 'ld-day--on': isDayOn(i) }"
|
||||
@click="toggleDay(i)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ld-applyall">
|
||||
Эти настройки применятся ко всем {{ selected.length }} проектам.
|
||||
После создания каждый можно настроить отдельно в разделе «Проекты».
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom action bar -->
|
||||
<div class="ld-actionbar">
|
||||
<div class="ld-selinfo">
|
||||
К созданию: <b>{{ selected.length }}</b> проектов
|
||||
</div>
|
||||
<div class="ld-actionbar__btns">
|
||||
<button class="ld-btn-ghost" @click="create(false)">
|
||||
Создать (без запуска)
|
||||
</button>
|
||||
<button class="ld-btn-primary" @click="create(true)">
|
||||
Создать и запустить →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-create-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.ld-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0 14px;
|
||||
border-bottom: 1px solid #e8e2d4;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ld-crumb {
|
||||
font-size: 13px;
|
||||
color: #7a7468;
|
||||
}
|
||||
|
||||
.ld-create-content {
|
||||
flex: 1;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.ld-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 18px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ld-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ld-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #012019;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.ld-sub {
|
||||
font-size: 13.5px;
|
||||
color: #7a7468;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.ld-alert {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
color: #856404;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ld-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e2d4;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ld-ctitle {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #012019;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ld-hint {
|
||||
font-size: 12.5px;
|
||||
color: #7a7468;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ld-mark-real {
|
||||
color: #0c5a46;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ld-mark-sub {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ld-srow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0ebe0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ld-srow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ld-stype {
|
||||
font-size: 11.5px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ld-stype--site {
|
||||
background: #e8f3ee;
|
||||
color: #0c5a46;
|
||||
border: 1px solid #cfe3da;
|
||||
}
|
||||
|
||||
.ld-stype--call {
|
||||
background: #edf3fb;
|
||||
color: #1a4f8a;
|
||||
border: 1px solid #c5d8ef;
|
||||
}
|
||||
|
||||
.ld-sident {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #012019;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.ld-sident--site {
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
}
|
||||
|
||||
.ld-derived-name {
|
||||
font-size: 13px;
|
||||
color: #7a7468;
|
||||
font-style: italic;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.ld-frow {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ld-fcol {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ld-flabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4a4540;
|
||||
margin: 0 0 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ld-req {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.ld-select {
|
||||
width: 100%;
|
||||
border: 1.5px solid #d5cfc2;
|
||||
border-radius: 7px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13.5px;
|
||||
color: #012019;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.ld-select:focus {
|
||||
border-color: var(--liderra-teal, #0f6e56);
|
||||
}
|
||||
|
||||
.ld-input {
|
||||
width: 100%;
|
||||
border: 1.5px solid #d5cfc2;
|
||||
border-radius: 7px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13.5px;
|
||||
color: #012019;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color 150ms;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ld-input:focus {
|
||||
border-color: var(--liderra-teal, #0f6e56);
|
||||
}
|
||||
|
||||
.ld-fhint {
|
||||
font-size: 12px;
|
||||
color: #9b9484;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.ld-days-wrap {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.ld-days {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.ld-day {
|
||||
border: 1.5px solid #d5cfc2;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
color: #7a7468;
|
||||
transition: background 150ms, color 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.ld-day--on {
|
||||
background: var(--liderra-teal, #0f6e56);
|
||||
color: #fff;
|
||||
border-color: var(--liderra-teal, #0f6e56);
|
||||
}
|
||||
|
||||
.ld-applyall {
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: #9b9484;
|
||||
background: #f6f3ec;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.ld-actionbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e2d4;
|
||||
padding: 12px 0;
|
||||
gap: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ld-selinfo {
|
||||
font-size: 13.5px;
|
||||
color: #4a4540;
|
||||
}
|
||||
|
||||
.ld-actionbar__btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ld-btn-primary {
|
||||
background: var(--liderra-teal, #0f6e56);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
.ld-btn-primary:hover:not(:disabled) {
|
||||
background: #0b5a45;
|
||||
}
|
||||
|
||||
.ld-btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ld-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
border: 1.5px solid var(--liderra-teal, #0f6e56);
|
||||
border-radius: 7px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
.ld-btn-ghost:hover {
|
||||
background: rgba(15, 110, 86, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,585 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, computed, ref } from 'vue';
|
||||
import { useAutopodborStore } from '../../../stores/autopodborStore';
|
||||
import type { SourceDto } from '../../../api/autopodbor';
|
||||
|
||||
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
|
||||
const store = useAutopodborStore();
|
||||
|
||||
const showAddSource = ref(false);
|
||||
const addSourceRaw = ref('');
|
||||
const addSourceLoading = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
if (nav.ctx.competitorId) {
|
||||
await store.loadCompetitor(nav.ctx.competitorId);
|
||||
// Auto-select sources without existing project
|
||||
nav.ctx.selectedSourceIds = store.sources
|
||||
.filter((s: SourceDto) => s.existing_project_id == null)
|
||||
.map((s: SourceDto) => s.id);
|
||||
}
|
||||
});
|
||||
|
||||
const sites = computed(() =>
|
||||
store.sources.filter((s: SourceDto) => s.signal_type === 'site'),
|
||||
);
|
||||
const calls = computed(() =>
|
||||
store.sources.filter((s: SourceDto) => s.signal_type === 'call'),
|
||||
);
|
||||
|
||||
const selectedCount = computed(() => nav.ctx.selectedSourceIds.length);
|
||||
const totalCount = computed(() => store.sources.length);
|
||||
|
||||
function isSelected(id: number): boolean {
|
||||
return nav.ctx.selectedSourceIds.includes(id);
|
||||
}
|
||||
|
||||
function toggleSource(id: number) {
|
||||
const idx = nav.ctx.selectedSourceIds.indexOf(id);
|
||||
if (idx === -1) {
|
||||
nav.ctx.selectedSourceIds.push(id);
|
||||
} else {
|
||||
nav.ctx.selectedSourceIds.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
nav.ctx.selectedSourceIds = [];
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
nav.go('create');
|
||||
}
|
||||
|
||||
function editProject(projectId: number) {
|
||||
nav.ctx.editProjectId = projectId;
|
||||
nav.go('editproject');
|
||||
}
|
||||
|
||||
async function doAddSource() {
|
||||
if (!addSourceRaw.value.trim() || !nav.ctx.competitorId) return;
|
||||
addSourceLoading.value = true;
|
||||
try {
|
||||
await store.addSource({ competitor_id: nav.ctx.competitorId, raw: addSourceRaw.value.trim() });
|
||||
addSourceRaw.value = '';
|
||||
showAddSource.value = false;
|
||||
} finally {
|
||||
addSourceLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ld-detail-screen">
|
||||
<!-- Topbar breadcrumb -->
|
||||
<div class="ld-topbar">
|
||||
<div class="ld-crumb">
|
||||
Автоподбор
|
||||
<template v-if="store.competitor"> · {{ store.competitor.name }}</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ld-detail-content">
|
||||
<!-- Back link -->
|
||||
<button class="ld-back" @click="nav.go('list')">← К списку конкурентов</button>
|
||||
|
||||
<!-- Competitor header -->
|
||||
<template v-if="store.competitor">
|
||||
<div class="ld-chead">
|
||||
<h1 class="ld-chead__name">
|
||||
{{ store.competitor.name }}
|
||||
<span v-if="store.competitor.is_federal" class="ld-badge ld-badge--fed">федеральный</span>
|
||||
</h1>
|
||||
<div v-if="store.competitor.relevance_pct !== null" class="ld-relbox">
|
||||
<div class="ld-relnum rel-100">{{ store.competitor.relevance_pct }}%</div>
|
||||
<div class="ld-rellbl">похожесть</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="store.competitor.studied_at" class="ld-studied">
|
||||
Изучено {{ store.competitor.studied_at }} · найдено {{ totalCount }} источников
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Explanatory note -->
|
||||
<div class="ld-note">
|
||||
Отметьте источники, по которым создать проекты. У каждого — ссылка «где нашли».
|
||||
<b>Подменный (с сайта)</b> — номер из коллтрекинга, его набирают клиенты с сайта;
|
||||
<b>настоящий</b> — линия из кода сайта или справочника. Берём оба.
|
||||
<b>Страница показывает актуальное состояние:</b>
|
||||
источники, по которым проект уже создан, помечены «✓ проект создан» — их можно изменить прямо здесь.
|
||||
</div>
|
||||
|
||||
<!-- Sites section -->
|
||||
<div v-if="sites.length" class="ld-sect">
|
||||
<div class="ld-secthd">
|
||||
🌐 Сайты
|
||||
<span class="ld-cnt">· {{ sites.length }} найдено · только головы доменов</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="src in sites"
|
||||
:key="src.id"
|
||||
class="ld-row"
|
||||
:class="{ 'ld-row--used': src.existing_project_id != null }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ld-cb"
|
||||
:checked="isSelected(src.id)"
|
||||
:disabled="src.existing_project_id != null"
|
||||
@change="toggleSource(src.id)"
|
||||
>
|
||||
<div class="ld-rinfo">
|
||||
<div class="ld-rident ld-rident--site">{{ src.identifier }}</div>
|
||||
<div class="ld-rprov">
|
||||
Где нашли:
|
||||
<a v-if="src.provenance_url" :href="src.provenance_url" target="_blank" rel="noopener">
|
||||
{{ src.provenance_label || src.provenance_url }}
|
||||
</a>
|
||||
<span v-else>{{ src.provenance_label }}</span>
|
||||
</div>
|
||||
<span v-if="src.existing_project_id != null" class="ld-used">✓ проект создан</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="src.existing_project_id != null"
|
||||
class="ld-btn-ghost ld-btn-ghost--sm"
|
||||
@click="editProject(src.existing_project_id!)"
|
||||
>
|
||||
Изменить проект →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calls section -->
|
||||
<div v-if="calls.length" class="ld-sect">
|
||||
<div class="ld-secthd">
|
||||
📞 Телефоны
|
||||
<span class="ld-cnt">· {{ calls.length }} найдено</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="src in calls"
|
||||
:key="src.id"
|
||||
class="ld-row"
|
||||
:class="{ 'ld-row--used': src.existing_project_id != null }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ld-cb"
|
||||
:checked="isSelected(src.id)"
|
||||
:disabled="src.existing_project_id != null"
|
||||
@change="toggleSource(src.id)"
|
||||
>
|
||||
<div class="ld-rinfo">
|
||||
<div class="ld-rident">
|
||||
{{ src.identifier }}
|
||||
<span v-if="src.phone_kind === 'real'" class="ld-tag ld-tag--real">настоящий</span>
|
||||
<span v-if="src.phone_kind === 'substitute'" class="ld-tag ld-tag--sub">подменный · с сайта</span>
|
||||
</div>
|
||||
<div class="ld-rprov">
|
||||
Где нашли:
|
||||
<a v-if="src.provenance_url" :href="src.provenance_url" target="_blank" rel="noopener">
|
||||
{{ src.provenance_label || src.provenance_url }}
|
||||
</a>
|
||||
<span v-else>{{ src.provenance_label }}</span>
|
||||
</div>
|
||||
<span v-if="src.existing_project_id != null" class="ld-used">✓ проект создан</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="src.existing_project_id != null"
|
||||
class="ld-btn-ghost ld-btn-ghost--sm"
|
||||
@click="editProject(src.existing_project_id!)"
|
||||
>
|
||||
Изменить проект →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual source add -->
|
||||
<div class="ld-addbox">
|
||||
<b>Чего-то не хватает?</b>
|
||||
<p>
|
||||
Знаете ещё сайт или номер этого конкурента —
|
||||
<span v-if="!showAddSource" class="ld-addlink" @click="showAddSource = true">
|
||||
добавьте источник вручную
|
||||
</span>
|
||||
<span v-else class="ld-addlink" @click="showAddSource = false">скрыть</span>.
|
||||
</p>
|
||||
<div v-if="showAddSource" class="ld-addsrc">
|
||||
<input
|
||||
v-model="addSourceRaw"
|
||||
class="ld-inp"
|
||||
placeholder="okna-komfort.ru · или +7 843 200-00-00"
|
||||
@keydown.enter="doAddSource"
|
||||
>
|
||||
<button
|
||||
class="ld-btn-primary ld-btn-primary--sm"
|
||||
:disabled="addSourceLoading || !addSourceRaw.trim()"
|
||||
@click="doAddSource"
|
||||
>
|
||||
Добавить источник
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom action bar -->
|
||||
<div class="ld-actionbar">
|
||||
<div class="ld-selinfo">
|
||||
Выбрано <b>{{ selectedCount }}</b> из {{ totalCount }} источников
|
||||
</div>
|
||||
<div class="ld-actionbar__btns">
|
||||
<button class="ld-btn-ghost" @click="clearSelection">Снять выбор</button>
|
||||
<button
|
||||
class="ld-btn-primary"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="goCreate"
|
||||
>
|
||||
Создать проекты →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-detail-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.ld-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0 14px;
|
||||
border-bottom: 1px solid #e8e2d4;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ld-crumb {
|
||||
font-size: 13px;
|
||||
color: #7a7468;
|
||||
}
|
||||
|
||||
.ld-detail-content {
|
||||
flex: 1;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.ld-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin-bottom: 18px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ld-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ld-chead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ld-chead__name {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #012019;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ld-badge {
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
margin-left: 6px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ld-badge--fed {
|
||||
background: #edf3fb;
|
||||
color: #1a4f8a;
|
||||
border: 1px solid #c5d8ef;
|
||||
}
|
||||
|
||||
.ld-relbox {
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ld-relnum {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ld-rellbl {
|
||||
font-size: 11px;
|
||||
color: #9b9484;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rel-100 { color: var(--liderra-teal, #0f6e56); }
|
||||
.rel-hi { color: #2e7d32; }
|
||||
.rel-mid { color: #b45309; }
|
||||
.rel-low { color: #9b9484; }
|
||||
|
||||
.ld-studied {
|
||||
font-size: 13px;
|
||||
color: #7a7468;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.ld-note {
|
||||
background: #f6f3ec;
|
||||
border: 1px solid #e8e2d4;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #4a4540;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ld-sect {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ld-secthd {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #012019;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ld-cnt {
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
color: #9b9484;
|
||||
}
|
||||
|
||||
.ld-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e8e2d4;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ld-row--used {
|
||||
background: #fbfaf5;
|
||||
}
|
||||
|
||||
.ld-cb {
|
||||
margin-top: 3px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ld-cb:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ld-rinfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ld-rident {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #012019;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ld-rident--site {
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
}
|
||||
|
||||
.ld-rprov {
|
||||
font-size: 12px;
|
||||
color: #7a7468;
|
||||
}
|
||||
|
||||
.ld-rprov a {
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ld-rprov a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ld-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ld-tag--real {
|
||||
background: #e8f3ee;
|
||||
color: #0c5a46;
|
||||
border: 1px solid #cfe3da;
|
||||
}
|
||||
|
||||
.ld-tag--sub {
|
||||
background: #fef9ec;
|
||||
color: #8a5c10;
|
||||
border: 1px solid #f0e0b0;
|
||||
}
|
||||
|
||||
.ld-used {
|
||||
display: inline-block;
|
||||
font-size: 11.5px;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ld-addbox {
|
||||
margin-top: 24px;
|
||||
background: #f6f3ec;
|
||||
border: 1px solid #e8e2d4;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
font-size: 13.5px;
|
||||
color: #4a4540;
|
||||
}
|
||||
|
||||
.ld-addbox b {
|
||||
color: #012019;
|
||||
}
|
||||
|
||||
.ld-addbox p {
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ld-addlink {
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.ld-addlink:hover {
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
.ld-addsrc {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ld-inp {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
border: 1.5px solid #d5cfc2;
|
||||
border-radius: 7px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13.5px;
|
||||
color: #012019;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.ld-inp:focus {
|
||||
border-color: var(--liderra-teal, #0f6e56);
|
||||
}
|
||||
|
||||
.ld-actionbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e2d4;
|
||||
padding: 12px 0;
|
||||
gap: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ld-selinfo {
|
||||
font-size: 13.5px;
|
||||
color: #4a4540;
|
||||
}
|
||||
|
||||
.ld-actionbar__btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ld-btn-primary {
|
||||
background: var(--liderra-teal, #0f6e56);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
.ld-btn-primary:hover:not(:disabled) {
|
||||
background: #0b5a45;
|
||||
}
|
||||
|
||||
.ld-btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ld-btn-primary--sm {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.ld-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
border: 1.5px solid var(--liderra-teal, #0f6e56);
|
||||
border-radius: 7px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
.ld-btn-ghost:hover {
|
||||
background: rgba(15, 110, 86, 0.06);
|
||||
}
|
||||
|
||||
.ld-btn-ghost--sm {
|
||||
padding: 7px 12px;
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
|
||||
const router = useRouter();
|
||||
|
||||
const message = computed(() => {
|
||||
const n = nav.ctx.createdCount ?? 0;
|
||||
const launched = nav.ctx.launched ?? false;
|
||||
return launched
|
||||
? `${n} ${projectsWord(n)} создано и запущено`
|
||||
: `${n} ${projectsWord(n)} создано`;
|
||||
});
|
||||
|
||||
function projectsWord(n: number): string {
|
||||
if (n === 1) return 'проект';
|
||||
if (n >= 2 && n <= 4) return 'проекта';
|
||||
return 'проектов';
|
||||
}
|
||||
|
||||
function goProjects(): void {
|
||||
void router.push('/projects');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ld-done-screen">
|
||||
<div class="ld-donewrap">
|
||||
<div class="ld-donecheck">✓</div>
|
||||
<p class="ld-donemsg">{{ message }}</p>
|
||||
<p class="ld-donesub">
|
||||
Проекты появились в разделе «Проекты». Первые лиды пойдут по правилу слепка.
|
||||
Конкурент и его источники сохранены — вернуться можно в любой момент без повторной оплаты.
|
||||
</p>
|
||||
<div class="ld-done-btns">
|
||||
<button class="ld-btn-ghost" @click="nav.go('entry')">← В начало</button>
|
||||
<button class="ld-btn-primary" @click="goProjects()">Перейти в «Проекты» →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ld-done-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.ld-donewrap {
|
||||
text-align: center;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.ld-donecheck {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--liderra-teal, #0f6e56);
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.ld-donemsg {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #012019;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.ld-donesub {
|
||||
font-size: 14px;
|
||||
color: #4a4540;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.ld-done-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ld-btn-primary {
|
||||
background: var(--liderra-teal, #0f6e56);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
.ld-btn-primary:hover {
|
||||
background: #0b5a45;
|
||||
}
|
||||
|
||||
.ld-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--liderra-teal, #0f6e56);
|
||||
border: 1.5px solid var(--liderra-teal, #0f6e56);
|
||||
border-radius: 7px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
.ld-btn-ghost:hover {
|
||||
background: rgba(15, 110, 86, 0.06);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user